From 5dbcb18b6a77d12b02ba3b6a72b218d5e8742bc3 Mon Sep 17 00:00:00 2001
From: Alex <55413326+AlexRubik@users.noreply.github.com>
Date: Tue, 9 Dec 2025 12:16:21 -0500
Subject: [PATCH 01/60] [PM-25037] add optional size input to app-vault-icon to
prevent zoom issues (#17640)
---
.../app-table-row-scrollable.component.html | 6 ++++-
.../src/vault/components/icon.component.html | 11 +++-----
.../src/vault/components/icon.component.ts | 26 ++++++++++++++++++-
3 files changed, 34 insertions(+), 9 deletions(-)
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html
index 76a03e0c525..0494f77bd46 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html
@@ -45,7 +45,11 @@
tabindex="0"
[attr.aria-label]="'viewItem' | i18n"
>
-
+
-
+
@if (data.imageEnabled && data.image) {
@@ -28,7 +24,7 @@
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
- [ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
+ [ngStyle]="iconStyle()"
>
diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts
index 851cec5656b..1037c9b3974 100644
--- a/libs/angular/src/vault/components/icon.component.ts
+++ b/libs/angular/src/vault/components/icon.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
+import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
combineLatest,
@@ -32,8 +32,32 @@ export class IconComponent {
*/
readonly coloredIcon = input (false);
+ /**
+ * Optional custom size for the icon in pixels.
+ * When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
+ * If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
+ * This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
+ * Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
+ */
+ readonly size = input();
+
readonly imageLoaded = signal(false);
+ /**
+ * Computed style object for icon dimensions.
+ * Centralizes the sizing logic to avoid repetition in the template.
+ */
+ protected readonly iconStyle = computed(() => {
+ if (this.coloredIcon()) {
+ return { width: "36px", height: "36px" };
+ }
+ const size = this.size();
+ if (size) {
+ return { width: size + "px", height: size + "px" };
+ }
+ return {};
+ });
+
protected data$: Observable;
constructor(
From c6576ceec8be222d76e011ddd6d7e79ae7a37cc4 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 17:51:25 +0000
Subject: [PATCH 02/60] [deps] Platform: Update nx monorepo to v21.6.10
(#17885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package-lock.json | 323 ++++++++++++++++++++--------------------------
package.json | 12 +-
2 files changed, 145 insertions(+), 190 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 63a8645083d..c1e8accdb94 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,11 +32,11 @@
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
"@ng-select/ng-select": "20.7.0",
- "@nx/devkit": "21.6.9",
- "@nx/eslint": "21.6.9",
- "@nx/jest": "21.6.9",
- "@nx/js": "21.6.9",
- "@nx/webpack": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/eslint": "21.6.10",
+ "@nx/jest": "21.6.10",
+ "@nx/js": "21.6.10",
+ "@nx/webpack": "21.6.10",
"big-integer": "1.6.52",
"braintree-web-drop-in": "1.46.0",
"buffer": "6.0.3",
@@ -156,7 +156,7 @@
"json5": "2.2.3",
"lint-staged": "16.0.0",
"mini-css-extract-plugin": "2.9.4",
- "nx": "21.6.9",
+ "nx": "21.6.10",
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
@@ -2848,7 +2848,6 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -9657,9 +9656,9 @@
}
},
"node_modules/@nx/devkit": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-21.6.9.tgz",
- "integrity": "sha512-Si7Lo5OgiHz/xU/NL1v5LnynE5oGrQmYE3KXxZoSRWij/nxZKi0wEB0W6dT3MtQW8RY1y5mg45Ti0Ym+Clhi8Q==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-21.6.10.tgz",
+ "integrity": "sha512-h2ZpwhKk9p1kWgokMXP6F4PVakUA3jPbKmjtY+wCsW2VZg72tIVVzs33DGUxTvN6WG6Z4xbLKc0LJkgaOdDTOw==",
"license": "MIT",
"dependencies": {
"ejs": "^3.1.7",
@@ -9684,13 +9683,13 @@
}
},
"node_modules/@nx/eslint": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-21.6.9.tgz",
- "integrity": "sha512-psd6GtWII5i1M15TTmdh8UZ/pBWlh6JtaVwlE5tk/GHlnCGXHEY+g3gKTsetjbuHjaocdwrfEy4TIB5J5Zh3HQ==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-21.6.10.tgz",
+ "integrity": "sha512-cZPXFZsgzGrOBetSdcIR9Kb28H9+lHsaubAGeCAjS8GSvRoQBKLdgtfuB5mpnmOLRqGsiIhZ701DfekLitRnmQ==",
"license": "MIT",
"dependencies": {
- "@nx/devkit": "21.6.9",
- "@nx/js": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/js": "21.6.10",
"semver": "^7.5.3",
"tslib": "^2.3.0",
"typescript": "~5.9.2"
@@ -9719,15 +9718,15 @@
}
},
"node_modules/@nx/jest": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-21.6.9.tgz",
- "integrity": "sha512-8x/B3f616ti2BUXHhOQqewMyCxMMmy++Wh1YiKr5S922m7jog1oYsCzue+fmHsNijw9xMNAgsDjgy91I/iZZ0Q==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-21.6.10.tgz",
+ "integrity": "sha512-JAYMD/RwKP/mgr7R0uC6R7/DGsluajiQsHipbp6JhbwmqxOK+tTdWBHrYzKWXyRZaCSqqmrN55ocVfuynZDP4Q==",
"license": "MIT",
"dependencies": {
"@jest/reporters": "^30.0.2",
"@jest/test-result": "^30.0.2",
- "@nx/devkit": "21.6.9",
- "@nx/js": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/js": "21.6.10",
"@phenomnomnominal/tsquery": "~5.0.1",
"identity-obj-proxy": "3.0.0",
"jest-config": "^30.0.2",
@@ -9741,22 +9740,6 @@
"yargs-parser": "21.1.1"
}
},
- "node_modules/@nx/jest/node_modules/@babel/generator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
- "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@babel/types": "^7.28.5",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@nx/jest/node_modules/@jest/console": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz",
@@ -10718,9 +10701,9 @@
}
},
"node_modules/@nx/js": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/js/-/js-21.6.9.tgz",
- "integrity": "sha512-KJnqe6W0Ly5AgpBOhygcVs5RssVKnKrISVp42CSirKx3nei6cus9VItwKBvBBAqmYw4AlrCe+/A2twTQCkeq1A==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/js/-/js-21.6.10.tgz",
+ "integrity": "sha512-8d+Q5v/9/he8mq6aRfhHWORZb/WkJ7OTegF4QX2g+yVkocEKIyuUx/BC9rGBRvlZpB2xcJlU9kNcfrhuoKbehQ==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.23.2",
@@ -10730,8 +10713,8 @@
"@babel/preset-env": "^7.23.2",
"@babel/preset-typescript": "^7.22.5",
"@babel/runtime": "^7.22.6",
- "@nx/devkit": "21.6.9",
- "@nx/workspace": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/workspace": "21.6.10",
"@zkochan/js-yaml": "0.0.7",
"babel-plugin-const-enum": "^1.0.1",
"babel-plugin-macros": "^3.1.0",
@@ -10870,9 +10853,9 @@
}
},
"node_modules/@nx/nx-darwin-arm64": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-21.6.9.tgz",
- "integrity": "sha512-rN5cJAjKvyXfi+Zep7wvSNtGr35X1/qrm96K/Sf4sybvowyHmDdEMYxkR6BPNT8ct5JGMm35xPfx1yF/rJek3w==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-21.6.10.tgz",
+ "integrity": "sha512-4K8oZdzil6zpY3zxugSbVDS4dF8o82KCeyT1IYH7t+aWD/tUnYhw/zmdNx6Jq80oxYgPrPWhxmuZ/UCN0LSYLw==",
"cpu": [
"arm64"
],
@@ -10883,9 +10866,9 @@
]
},
"node_modules/@nx/nx-darwin-x64": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-21.6.9.tgz",
- "integrity": "sha512-rb/Dtum094nfJL8lYohne1duZr8uNQ4gvWTq/Cw/xowJwXGq3xzsSS2WTpDpRBMF45K+42fipGHNeHbCyYSF7g==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-21.6.10.tgz",
+ "integrity": "sha512-WqFIRjxtOHoJob2f24YiKfgqTcgtVb/CKYvnuMAmKccarOi91DeABQO35gXUwvE89TjhlR5slG5YLZt7E5UCaQ==",
"cpu": [
"x64"
],
@@ -10896,9 +10879,9 @@
]
},
"node_modules/@nx/nx-freebsd-x64": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-21.6.9.tgz",
- "integrity": "sha512-Cd7QHeivvLBiQ6iRTsvprGk1YS+CaUCMw4A+3TOvHz608a/U3mEye8oRy2fyFTTL/lsH6dlihT3xi+HNyXKAyA==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-21.6.10.tgz",
+ "integrity": "sha512-EqrBLRA0WRek+x3kH6/YL+fRa6xKvj9e9nRfOYyo0GSbUwew5ofGWODGoYtoHC+oCuL4qtpKGRhU27NFwhOM8A==",
"cpu": [
"x64"
],
@@ -10909,9 +10892,9 @@
]
},
"node_modules/@nx/nx-linux-arm-gnueabihf": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-21.6.9.tgz",
- "integrity": "sha512-ASXay2jKhSU4tfY9Z2ByysqDQxYgTHCtoJ+XR5xRv9aoIos6oYeKAqQV/RLXpTklugu08nBtL/4IRw58x4oU4A==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-21.6.10.tgz",
+ "integrity": "sha512-CdbPy4s1I4f57DOncoSsnJX9dB2f7sZhdPXHKZ9tgCMcBpy6uYHhkzmrwCdiBjl/2JQLM/GwEkqoYxpzIlAJbA==",
"cpu": [
"arm"
],
@@ -10922,9 +10905,9 @@
]
},
"node_modules/@nx/nx-linux-arm64-gnu": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-21.6.9.tgz",
- "integrity": "sha512-1VS38xnAC8iH05A0nnbNn1hi9ypRnEPUfgLL3tPhAwQTWX2DQz4xR/j0NYNcCzL6yBe/JhdKlYoN/LI38lj2UA==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-21.6.10.tgz",
+ "integrity": "sha512-4ZSjvCjnBT0WpGdF12hvgLWmok4WftaE09fOWWrMm4b2m8F/5yKgU6usPFTehQa5oqTp08KW60kZMLaOQHOJQg==",
"cpu": [
"arm64"
],
@@ -10935,9 +10918,9 @@
]
},
"node_modules/@nx/nx-linux-arm64-musl": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-21.6.9.tgz",
- "integrity": "sha512-PScHPs0dp+Cc17RvY4Y5wlDXT6xdDlsyhna2JLawodVCyUVArtnbF7whn/VEZKesDD/vAf1avCt4oAjuYS8VXg==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-21.6.10.tgz",
+ "integrity": "sha512-lNzlTsgr7nY56ddIpLTzYZTuNA3FoeWb9Ald07pCWc0EHSZ0W4iatJ+NNnj/QLINW8HWUehE9mAV5qZlhVFBmg==",
"cpu": [
"arm64"
],
@@ -10948,9 +10931,9 @@
]
},
"node_modules/@nx/nx-linux-x64-gnu": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-21.6.9.tgz",
- "integrity": "sha512-s8oX6/pLolHH3EyFJPcKITv+rzN/IZuidMCNkGfcr0jYVqrTZcJo8xUEwAQzf6u6J6urOm0bUK3BDuwJLEKESg==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-21.6.10.tgz",
+ "integrity": "sha512-nJxUtzcHwk8TgDdcqUmbJnEMV3baQxmdWn77d1NTP4cG677A7jdV93hbnCcw+AQonaFLUzDwJOIX8eIPZ32GLw==",
"cpu": [
"x64"
],
@@ -10961,9 +10944,9 @@
]
},
"node_modules/@nx/nx-linux-x64-musl": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-21.6.9.tgz",
- "integrity": "sha512-bojpGcscRrnet5N3waeHYnBHW0y6r5tSQ1phnwMjgoBFmWXw+0M+z/f2dfZcTtBmWc7Y/TnzaGb8EenC3a63cQ==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-21.6.10.tgz",
+ "integrity": "sha512-+VwITTQW9wswP7EvFzNOucyaU86l2UcO6oYxFiwNvRioTlDOE5U7lxYmCgj3OHeGCmy9jhXlujdD+t3OhOT3gQ==",
"cpu": [
"x64"
],
@@ -10974,9 +10957,9 @@
]
},
"node_modules/@nx/nx-win32-arm64-msvc": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-21.6.9.tgz",
- "integrity": "sha512-cS1bdMiJBs4AcykJ3+vtAdw4RkZLLfXT20o+k07dEskRFADIa5yXdOs2j0qKoe7iCiORKCH+gI/YsPHCyHfV9Q==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-21.6.10.tgz",
+ "integrity": "sha512-kkK/0GNVs7pdcgksLfoMBT8k92XGfcePPuhhS1Tsyq+zc3gpsPo+vNIGfeIf2FumKBsUdWUHuChfpxBmjcVFVw==",
"cpu": [
"arm64"
],
@@ -10987,9 +10970,9 @@
]
},
"node_modules/@nx/nx-win32-x64-msvc": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-21.6.9.tgz",
- "integrity": "sha512-EX0ja8gWnmomiSbK9K58oATpTn/+KU6RKcrfzqA3yL5x/a+kEPSf66QOXGQjDpCGKWMoxN+6ex7zhpmqbqKxgg==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-21.6.10.tgz",
+ "integrity": "sha512-ddYZv1Z8wLhlHASwi044gTcM0+7OJ24V1yCwlVe3wsIqZDUZvVC1Lgk+wIQXUH8mBKm3NZti8B72nldoofOmSw==",
"cpu": [
"x64"
],
@@ -11000,14 +10983,14 @@
]
},
"node_modules/@nx/webpack": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-21.6.9.tgz",
- "integrity": "sha512-2RWiZ4G/1VhEUTJtSH6zo9bvMxpRlV9AQGV3/NnP/dyH/owbZXrDuzd/hGW7s5CNE0RB3oN2dZG/ZEFJcGw55Q==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-21.6.10.tgz",
+ "integrity": "sha512-T+eB9c3lflqWuegrsW47zzkZlSQ6YNEucEknUpWyDrKLCihucKe9siuj5s2gPkgdY6DXX4sjZcA5xgnxHNBWag==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.23.2",
- "@nx/devkit": "21.6.9",
- "@nx/js": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/js": "21.6.10",
"@phenomnomnominal/tsquery": "~5.0.1",
"ajv": "^8.12.0",
"autoprefixer": "^10.4.9",
@@ -11043,18 +11026,6 @@
"webpack-subresource-integrity": "^5.1.0"
}
},
- "node_modules/@nx/webpack/node_modules/array-union": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz",
- "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/@nx/webpack/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -11267,35 +11238,6 @@
"node": ">=12"
}
},
- "node_modules/@nx/webpack/node_modules/globby": {
- "version": "12.2.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
- "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==",
- "license": "MIT",
- "dependencies": {
- "array-union": "^3.0.1",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.7",
- "ignore": "^5.1.9",
- "merge2": "^1.4.1",
- "slash": "^4.0.0"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@nx/webpack/node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/@nx/webpack/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -11364,15 +11306,6 @@
"node": "*"
}
},
- "node_modules/@nx/webpack/node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@nx/webpack/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -11436,18 +11369,6 @@
"node": ">=8.10.0"
}
},
- "node_modules/@nx/webpack/node_modules/slash": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
- "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/@nx/webpack/node_modules/style-loader": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
@@ -11474,16 +11395,16 @@
}
},
"node_modules/@nx/workspace": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-21.6.9.tgz",
- "integrity": "sha512-tUucr8hrpdhFITMjEEF8vm1j0GSW0ecFTySViWnnVvYyyv7tbidK/76MV/iyV/SjSamOHm2zIXS9fCfXV4LpAQ==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-21.6.10.tgz",
+ "integrity": "sha512-6OkXs4gAVjDtrfqhJf7lHZX/VlCFLRZpywfgvmije40wrExkJDNEHx3Gf6dvSVwl0vE6Gz8D2t6luO02hGGz4w==",
"license": "MIT",
"dependencies": {
- "@nx/devkit": "21.6.9",
+ "@nx/devkit": "21.6.10",
"@zkochan/js-yaml": "0.0.7",
"chalk": "^4.1.0",
"enquirer": "~2.3.6",
- "nx": "21.6.9",
+ "nx": "21.6.10",
"picomatch": "4.0.2",
"semver": "^7.6.3",
"tslib": "^2.3.0",
@@ -16942,15 +16863,6 @@
"node": ">=10"
}
},
- "node_modules/babel-plugin-macros/node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/babel-plugin-macros/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
@@ -20267,15 +20179,6 @@
"node": ">=8"
}
},
- "node_modules/dir-glob/node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -22883,16 +22786,6 @@
"node": "*"
}
},
- "node_modules/fork-ts-checker-webpack-plugin/node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/fork-ts-checker-webpack-plugin/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -23479,6 +23372,59 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/globby": {
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
+ "integrity": "sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==",
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^3.0.1",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.7",
+ "ignore": "^5.1.9",
+ "merge2": "^1.4.1",
+ "slash": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globby/node_modules/array-union": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz",
+ "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globby/node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/globby/node_modules/slash": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
+ "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
@@ -31851,9 +31797,9 @@
"license": "MIT"
},
"node_modules/nx": {
- "version": "21.6.9",
- "resolved": "https://registry.npmjs.org/nx/-/nx-21.6.9.tgz",
- "integrity": "sha512-RPuIb04QIOE2WLDcvKDjrAQlkI9+EnP8/9KyG/I296JA1lJhlIk7BH3F6Py7uLHD7B1adSBsCDf/tT6540Ng7A==",
+ "version": "21.6.10",
+ "resolved": "https://registry.npmjs.org/nx/-/nx-21.6.10.tgz",
+ "integrity": "sha512-iKSyAg0VGG1MEOnlyyseMOt4n9J7I955VC+0UPQbNQTLdIUW8ibIHubpQyjd8Qvq4CfrLxzm+iq1AmbZ5vEG4A==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -31898,16 +31844,16 @@
"nx-cloud": "bin/nx-cloud.js"
},
"optionalDependencies": {
- "@nx/nx-darwin-arm64": "21.6.9",
- "@nx/nx-darwin-x64": "21.6.9",
- "@nx/nx-freebsd-x64": "21.6.9",
- "@nx/nx-linux-arm-gnueabihf": "21.6.9",
- "@nx/nx-linux-arm64-gnu": "21.6.9",
- "@nx/nx-linux-arm64-musl": "21.6.9",
- "@nx/nx-linux-x64-gnu": "21.6.9",
- "@nx/nx-linux-x64-musl": "21.6.9",
- "@nx/nx-win32-arm64-msvc": "21.6.9",
- "@nx/nx-win32-x64-msvc": "21.6.9"
+ "@nx/nx-darwin-arm64": "21.6.10",
+ "@nx/nx-darwin-x64": "21.6.10",
+ "@nx/nx-freebsd-x64": "21.6.10",
+ "@nx/nx-linux-arm-gnueabihf": "21.6.10",
+ "@nx/nx-linux-arm64-gnu": "21.6.10",
+ "@nx/nx-linux-arm64-musl": "21.6.10",
+ "@nx/nx-linux-x64-gnu": "21.6.10",
+ "@nx/nx-linux-x64-musl": "21.6.10",
+ "@nx/nx-win32-arm64-msvc": "21.6.10",
+ "@nx/nx-win32-x64-msvc": "21.6.10"
},
"peerDependencies": {
"@swc-node/register": "^1.8.0",
@@ -33419,6 +33365,15 @@
"node": ">=16"
}
},
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
diff --git a/package.json b/package.json
index 3468d567477..0e723e7097c 100644
--- a/package.json
+++ b/package.json
@@ -118,7 +118,7 @@
"json5": "2.2.3",
"lint-staged": "16.0.0",
"mini-css-extract-plugin": "2.9.4",
- "nx": "21.6.9",
+ "nx": "21.6.10",
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
@@ -166,11 +166,11 @@
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
"@ng-select/ng-select": "20.7.0",
- "@nx/devkit": "21.6.9",
- "@nx/eslint": "21.6.9",
- "@nx/jest": "21.6.9",
- "@nx/js": "21.6.9",
- "@nx/webpack": "21.6.9",
+ "@nx/devkit": "21.6.10",
+ "@nx/eslint": "21.6.10",
+ "@nx/jest": "21.6.10",
+ "@nx/js": "21.6.10",
+ "@nx/webpack": "21.6.10",
"big-integer": "1.6.52",
"braintree-web-drop-in": "1.46.0",
"buffer": "6.0.3",
From 0af5e5630bb1d316e98ca3900389f0b3446aeb04 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 11:24:44 -0700
Subject: [PATCH 03/60] [deps] Platform: Update @types/node to v22.19.2
(#17878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.../native-messaging-test-runner/package-lock.json | 8 ++++----
apps/desktop/native-messaging-test-runner/package.json | 2 +-
package-lock.json | 8 ++++----
package.json | 2 +-
4 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json
index 9ad1ffb3ec0..1f4a56de18a 100644
--- a/apps/desktop/native-messaging-test-runner/package-lock.json
+++ b/apps/desktop/native-messaging-test-runner/package-lock.json
@@ -19,7 +19,7 @@
"yargs": "18.0.0"
},
"devDependencies": {
- "@types/node": "22.19.1",
+ "@types/node": "22.19.2",
"typescript": "5.4.2"
}
},
@@ -117,9 +117,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.19.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
- "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+ "version": "22.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
+ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"license": "MIT",
"peer": true,
"dependencies": {
diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json
index 21a6ba3626a..83e9f01afed 100644
--- a/apps/desktop/native-messaging-test-runner/package.json
+++ b/apps/desktop/native-messaging-test-runner/package.json
@@ -24,7 +24,7 @@
"yargs": "18.0.0"
},
"devDependencies": {
- "@types/node": "22.19.1",
+ "@types/node": "22.19.2",
"typescript": "5.4.2"
},
"_moduleAliases": {
diff --git a/package-lock.json b/package-lock.json
index c1e8accdb94..cf9b6becf2f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -109,7 +109,7 @@
"@types/koa-json": "2.0.23",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
- "@types/node": "22.19.1",
+ "@types/node": "22.19.2",
"@types/node-fetch": "2.6.4",
"@types/node-forge": "1.3.14",
"@types/papaparse": "5.5.0",
@@ -14111,9 +14111,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.19.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
- "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+ "version": "22.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
+ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
diff --git a/package.json b/package.json
index 0e723e7097c..58ff0ba8ea5 100644
--- a/package.json
+++ b/package.json
@@ -71,7 +71,7 @@
"@types/koa-json": "2.0.23",
"@types/lowdb": "1.0.15",
"@types/lunr": "2.3.7",
- "@types/node": "22.19.1",
+ "@types/node": "22.19.2",
"@types/node-fetch": "2.6.4",
"@types/node-forge": "1.3.14",
"@types/papaparse": "5.5.0",
From 508131ac1e2562b4279902376f9ac379e35f1090 Mon Sep 17 00:00:00 2001
From: Oscar Hinton
Date: Tue, 9 Dec 2025 19:26:26 +0100
Subject: [PATCH 04/60] [PM-294489 Extract send table to libs/tools (#17841)
* Extract send table to libs/tools so it can be re-used in desktop
* Remove *ngIf
* Add story
* Move story under sends
* Remove duplicate class
* Move the dates to prevent any changes in storybook
* Revert changes to web component to speed up merge
---
libs/tools/send/send-ui/src/index.ts | 1 +
.../src/send-table/send-table.component.html | 102 ++++++++++++++++++
.../send-table.component.stories.ts | 100 +++++++++++++++++
.../src/send-table/send-table.component.ts | 92 ++++++++++++++++
4 files changed, 295 insertions(+)
create mode 100644 libs/tools/send/send-ui/src/send-table/send-table.component.html
create mode 100644 libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts
create mode 100644 libs/tools/send/send-ui/src/send-table/send-table.component.ts
diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts
index 2803e91c418..ac8b9383681 100644
--- a/libs/tools/send/send-ui/src/index.ts
+++ b/libs/tools/send/send-ui/src/index.ts
@@ -6,3 +6,4 @@ export { SendItemsService } from "./services/send-items.service";
export { SendSearchComponent } from "./send-search/send-search.component";
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
export { SendListFiltersService } from "./services/send-list-filters.service";
+export { SendTableComponent } from "./send-table/send-table.component";
diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html
new file mode 100644
index 00000000000..6b93b9d879e
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html
@@ -0,0 +1,102 @@
+
+
+
+ | {{ "name" | i18n }} |
+ {{ "deletionDate" | i18n }} |
+ {{ "options" | i18n }} |
+
+
+
+
+ |
+
+
+ @if (s.type == sendType.File) {
+
+ }
+ @if (s.type == sendType.Text) {
+
+ }
+
+
+ @if (s.disabled) {
+
+ {{ "disabled" | i18n }}
+ }
+ @if (s.password) {
+
+ {{ "password" | i18n }}
+ }
+ @if (s.maxAccessCountReached) {
+
+ {{ "maxAccessCountReached" | i18n }}
+ }
+ @if (s.expired) {
+
+ {{ "expired" | i18n }}
+ }
+ @if (s.pendingDelete) {
+
+ {{ "pendingDeletion" | i18n }}
+ }
+
+ |
+
+ {{ s.deletionDate | date: "medium" }}
+ |
+
+
+
+
+ @if (s.password && !disableSend()) {
+
+ }
+
+
+ |
+
+
+
diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts
new file mode 100644
index 00000000000..d2d630b69a2
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts
@@ -0,0 +1,100 @@
+import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
+import { TableDataSource, I18nMockService } from "@bitwarden/components";
+
+import { SendTableComponent } from "./send-table.component";
+
+function createMockSend(id: number, overrides: Partial = {}): SendView {
+ const send = new SendView();
+
+ send.id = `send-${id}`;
+ send.name = "My Send";
+ send.type = SendType.Text;
+ send.deletionDate = new Date("2030-01-01T12:00:00Z");
+ send.password = null as any;
+
+ Object.assign(send, overrides);
+
+ return send;
+}
+
+const dataSource = new TableDataSource();
+dataSource.data = [
+ createMockSend(0, {
+ name: "Project Documentation",
+ type: SendType.Text,
+ }),
+ createMockSend(1, {
+ name: "Meeting Notes",
+ type: SendType.File,
+ }),
+ createMockSend(2, {
+ name: "Password Protected Send",
+ type: SendType.Text,
+ password: "123",
+ }),
+ createMockSend(3, {
+ name: "Disabled Send",
+ type: SendType.Text,
+ disabled: true,
+ }),
+ createMockSend(4, {
+ name: "Expired Send",
+ type: SendType.File,
+ expirationDate: new Date("2025-12-01T00:00:00Z"),
+ }),
+ createMockSend(5, {
+ name: "Max Access Reached",
+ type: SendType.Text,
+ maxAccessCount: 5,
+ accessCount: 5,
+ password: "123",
+ }),
+];
+
+export default {
+ title: "Tools/Sends/Send Table",
+ component: SendTableComponent,
+ decorators: [
+ moduleMetadata({
+ providers: [
+ {
+ provide: I18nService,
+ useFactory: () => {
+ return new I18nMockService({
+ name: "Name",
+ deletionDate: "Deletion Date",
+ options: "Options",
+ disabled: "Disabled",
+ password: "Password",
+ maxAccessCountReached: "Max access count reached",
+ expired: "Expired",
+ pendingDeletion: "Pending deletion",
+ copySendLink: "Copy Send link",
+ removePassword: "Remove password",
+ delete: "Delete",
+ loading: "Loading",
+ });
+ },
+ },
+ ],
+ }),
+ ],
+ args: {
+ dataSource,
+ disableSend: false,
+ },
+ argTypes: {
+ editSend: { action: "editSend" },
+ copySend: { action: "copySend" },
+ removePassword: { action: "removePassword" },
+ deleteSend: { action: "deleteSend" },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts
new file mode 100644
index 00000000000..c912a01f98a
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts
@@ -0,0 +1,92 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
+import {
+ BadgeModule,
+ ButtonModule,
+ IconButtonModule,
+ LinkModule,
+ MenuModule,
+ TableDataSource,
+ TableModule,
+ TypographyModule,
+} from "@bitwarden/components";
+
+/**
+ * A table component for displaying Send items with sorting, status indicators, and action menus. Handles the presentation of sends in a tabular format with options
+ * for editing, copying links, removing passwords, and deleting.
+ */
+@Component({
+ selector: "tools-send-table",
+ templateUrl: "./send-table.component.html",
+ imports: [
+ CommonModule,
+ JslibModule,
+ TableModule,
+ ButtonModule,
+ LinkModule,
+ IconButtonModule,
+ MenuModule,
+ BadgeModule,
+ TypographyModule,
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SendTableComponent {
+ protected readonly sendType = SendType;
+
+ /**
+ * The data source containing the Send items to display in the table.
+ */
+ readonly dataSource = input>();
+
+ /**
+ * Whether Send functionality is disabled by policy.
+ * When true, the "Remove Password" option is hidden from the action menu.
+ */
+ readonly disableSend = input(false);
+
+ /**
+ * Emitted when a user clicks on a Send item to edit it.
+ * The clicked SendView is passed as the event payload.
+ */
+ readonly editSend = output();
+
+ /**
+ * Emitted when a user clicks the "Copy Send Link" action.
+ * The SendView is passed as the event payload for generating and copying the link.
+ */
+ readonly copySend = output();
+
+ /**
+ * Emitted when a user clicks the "Remove Password" action.
+ * The SendView is passed as the event payload for password removal.
+ * This action is only available if the Send has a password and Send is not disabled.
+ */
+ readonly removePassword = output();
+
+ /**
+ * Emitted when a user clicks the "Delete" action.
+ * The SendView is passed as the event payload for deletion.
+ */
+ readonly deleteSend = output();
+
+ protected onEditSend(send: SendView): void {
+ this.editSend.emit(send);
+ }
+
+ protected onCopy(send: SendView): void {
+ this.copySend.emit(send);
+ }
+
+ protected onRemovePassword(send: SendView): void {
+ this.removePassword.emit(send);
+ }
+
+ protected onDelete(send: SendView): void {
+ this.deleteSend.emit(send);
+ }
+}
From 5c64bf51fc795298d5465b087db558a741ae46d7 Mon Sep 17 00:00:00 2001
From: Daniel Riera
Date: Tue, 9 Dec 2025 14:37:29 -0500
Subject: [PATCH 05/60] PM-28614 Set explicit protocols for isExtensionUrl
function for inline menu (#17782)
---
.../pages/menu-container/autofill-inline-menu-container.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts
index 6c61cfae6b4..84a15fd1067 100644
--- a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts
+++ b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts
@@ -129,7 +129,12 @@ export class AutofillInlineMenuContainer {
}
try {
const urlObj = new URL(url);
- const isExtensionProtocol = /^[a-z]+(-[a-z]+)?-extension:$/i.test(urlObj.protocol);
+ const extensionProtocols = new Set([
+ "chrome-extension:",
+ "moz-extension:",
+ "safari-web-extension:",
+ ]);
+ const isExtensionProtocol = extensionProtocols.has(urlObj.protocol);
if (!isExtensionProtocol) {
return false;
From c84ebc97dafb35307d79f435fecf693cd70dd3ce Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 20:05:57 +0000
Subject: [PATCH 06/60] [deps]: Update Rust crate cc to v1.2.48 (#17746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 4 ++--
apps/desktop/desktop_native/objc/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 3d528d49d67..e7d3a88a7e7 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -556,9 +556,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.46"
+version = "1.2.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
+checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a"
dependencies = [
"find-msvc-tools",
"shlex",
diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml
index 5ef791fb586..cb9c1606274 100644
--- a/apps/desktop/desktop_native/objc/Cargo.toml
+++ b/apps/desktop/desktop_native/objc/Cargo.toml
@@ -14,7 +14,7 @@ tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.build-dependencies]
-cc = "=1.2.46"
+cc = "=1.2.48"
glob = "=0.3.2"
[lints]
From e03e5f1b2b16185d4cb0d42df74979d6875ecf40 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 20:14:00 +0000
Subject: [PATCH 07/60] [deps] Platform: Update Rust crate homedir to v0.3.6
(#17548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 105 +++++--------------------
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 21 insertions(+), 86 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index e7d3a88a7e7..603656df37d 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -347,8 +347,8 @@ dependencies = [
"mockall",
"serial_test",
"tracing",
- "windows 0.61.1",
- "windows-core 0.61.0",
+ "windows",
+ "windows-core",
]
[[package]]
@@ -457,7 +457,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
- "windows 0.61.1",
+ "windows",
]
[[package]]
@@ -623,7 +623,7 @@ dependencies = [
"tokio",
"tracing",
"verifysign",
- "windows 0.61.1",
+ "windows",
]
[[package]]
@@ -883,7 +883,7 @@ dependencies = [
"tracing",
"typenum",
"widestring",
- "windows 0.61.1",
+ "windows",
"windows-future",
"zbus",
"zbus_polkit",
@@ -1499,14 +1499,14 @@ dependencies = [
[[package]]
name = "homedir"
-version = "0.3.4"
+version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
+checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
dependencies = [
"cfg-if",
- "nix 0.29.0",
+ "nix",
"widestring",
- "windows 0.57.0",
+ "windows",
]
[[package]]
@@ -1945,18 +1945,6 @@ dependencies = [
"libloading",
]
-[[package]]
-name = "nix"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
-dependencies = [
- "bitflags",
- "cfg-if",
- "cfg_aliases",
- "libc",
-]
-
[[package]]
name = "nix"
version = "0.30.1"
@@ -2871,7 +2859,7 @@ dependencies = [
"rustix 1.0.7",
"rustix-linux-procfs",
"thiserror 2.0.12",
- "windows 0.61.1",
+ "windows",
]
[[package]]
@@ -3197,7 +3185,7 @@ dependencies = [
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
- "windows 0.61.1",
+ "windows",
]
[[package]]
@@ -3852,16 +3840,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows"
-version = "0.57.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
-dependencies = [
- "windows-core 0.57.0",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows"
version = "0.61.1"
@@ -3869,7 +3847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [
"windows-collections",
- "windows-core 0.61.0",
+ "windows-core",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
@@ -3881,19 +3859,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
- "windows-core 0.61.0",
-]
-
-[[package]]
-name = "windows-core"
-version = "0.57.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
-dependencies = [
- "windows-implement 0.57.0",
- "windows-interface 0.57.0",
- "windows-result 0.1.2",
- "windows-targets 0.52.6",
+ "windows-core",
]
[[package]]
@@ -3902,8 +3868,8 @@ version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
- "windows-implement 0.60.0",
- "windows-interface 0.59.1",
+ "windows-implement",
+ "windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -3915,21 +3881,10 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
- "windows-core 0.61.0",
+ "windows-core",
"windows-link 0.1.3",
]
-[[package]]
-name = "windows-implement"
-version = "0.57.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -3941,17 +3896,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "windows-interface"
-version = "0.57.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "windows-interface"
version = "0.59.1"
@@ -3981,7 +3925,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
- "windows-core 0.61.0",
+ "windows-core",
"windows-link 0.1.3",
]
@@ -3996,15 +3940,6 @@ dependencies = [
"windows-strings 0.5.1",
]
-[[package]]
-name = "windows-result"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4262,8 +4197,8 @@ name = "windows_plugin_authenticator"
version = "0.0.0"
dependencies = [
"hex",
- "windows 0.61.1",
- "windows-core 0.61.0",
+ "windows",
+ "windows-core",
]
[[package]]
@@ -4452,7 +4387,7 @@ dependencies = [
"futures-core",
"futures-lite",
"hex",
- "nix 0.30.1",
+ "nix",
"ordered-stream",
"serde",
"serde_repr",
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index f8ee329ed5e..c1a3e98a0de 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -37,7 +37,7 @@ ed25519 = "=2.2.3"
embed_plist = "=1.2.2"
futures = "=0.3.31"
hex = "=0.4.3"
-homedir = "=0.3.4"
+homedir = "=0.3.6"
interprocess = "=2.2.1"
libc = "=0.2.178"
linux-keyutils = "=0.2.4"
From 488a786b86e7bd3ba3b770ea1815685f646a3b34 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 14:35:03 -0600
Subject: [PATCH 08/60] [deps]: Update Rust crate glob to v0.3.3 (#17573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 4 ++--
apps/desktop/desktop_native/objc/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 603656df37d..7b763e274b4 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -1417,9 +1417,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "glob"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "goblin"
diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml
index cb9c1606274..5d7174894e7 100644
--- a/apps/desktop/desktop_native/objc/Cargo.toml
+++ b/apps/desktop/desktop_native/objc/Cargo.toml
@@ -15,7 +15,7 @@ tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.build-dependencies]
cc = "=1.2.48"
-glob = "=0.3.2"
+glob = "=0.3.3"
[lints]
workspace = true
From 2cd12d961105cd90500e363fe3e0c3093606e018 Mon Sep 17 00:00:00 2001
From: Oscar Hinton
Date: Tue, 9 Dec 2025 21:40:00 +0100
Subject: [PATCH 09/60] [CL-434] Swap extension to use tailwind preflight
(#17054)
Co-authored-by: Vicki League
---
.../current-account.component.html | 2 +-
.../layout/popup-tab-navigation.component.ts | 2 +-
apps/browser/src/popup/app.component.html | 9 +-
apps/browser/src/popup/scss/base.scss | 453 -------------
apps/browser/src/popup/scss/box.scss | 620 ------------------
apps/browser/src/popup/scss/buttons.scss | 118 ----
apps/browser/src/popup/scss/environment.scss | 43 --
apps/browser/src/popup/scss/grid.scss | 11 -
apps/browser/src/popup/scss/misc.scss | 348 ----------
apps/browser/src/popup/scss/pages.scss | 144 ----
apps/browser/src/popup/scss/plugins.scss | 23 -
apps/browser/src/popup/scss/popup.scss | 53 +-
apps/browser/src/popup/scss/tailwind.css | 157 ++++-
apps/browser/src/popup/scss/variables.scss | 146 +----
apps/browser/tailwind.config.js | 1 +
15 files changed, 215 insertions(+), 1915 deletions(-)
delete mode 100644 apps/browser/src/popup/scss/base.scss
delete mode 100644 apps/browser/src/popup/scss/box.scss
delete mode 100644 apps/browser/src/popup/scss/buttons.scss
delete mode 100644 apps/browser/src/popup/scss/environment.scss
delete mode 100644 apps/browser/src/popup/scss/grid.scss
delete mode 100644 apps/browser/src/popup/scss/misc.scss
delete mode 100644 apps/browser/src/popup/scss/pages.scss
delete mode 100644 apps/browser/src/popup/scss/plugins.scss
diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html
index 2e2440f6258..7ab55f36753 100644
--- a/apps/browser/src/auth/popup/account-switching/current-account.component.html
+++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html
@@ -2,7 +2,7 @@
} @else {
-
-
+
+
-
}
diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss
deleted file mode 100644
index 01b9d3f05d5..00000000000
--- a/apps/browser/src/popup/scss/base.scss
+++ /dev/null
@@ -1,453 +0,0 @@
-@import "variables.scss";
-
-* {
- box-sizing: border-box;
- padding: 0;
- margin: 0;
-}
-
-html {
- overflow: hidden;
- min-height: 600px;
- height: 100%;
-
- &.body-sm {
- min-height: 500px;
- }
-
- &.body-xs {
- min-height: 400px;
- }
-
- &.body-xxs {
- min-height: 300px;
- }
-
- &.body-3xs {
- min-height: 240px;
- }
-
- &.body-full {
- min-height: unset;
- width: 100%;
- height: 100%;
-
- & body {
- width: 100%;
- }
- }
-}
-
-html,
-body {
- font-family: $font-family-sans-serif;
- font-size: $font-size-base;
- line-height: $line-height-base;
- -webkit-font-smoothing: antialiased;
-}
-
-body {
- width: 380px;
- height: 100%;
- position: relative;
- min-height: inherit;
- overflow: hidden;
- color: $text-color;
- background-color: $background-color;
-
- @include themify($themes) {
- color: themed("textColor");
- background-color: themed("backgroundColor");
- }
-}
-
-h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
- font-family: $font-family-sans-serif;
- font-size: $font-size-base;
- font-weight: normal;
-}
-
-p {
- margin-bottom: 10px;
-}
-
-ul,
-ol {
- margin-bottom: 10px;
-}
-
-img {
- border: none;
-}
-
-a:not(popup-page a, popup-tab-navigation a) {
- text-decoration: none;
-
- @include themify($themes) {
- color: themed("primaryColor");
- }
-
- &:hover,
- &:focus {
- @include themify($themes) {
- color: darken(themed("primaryColor"), 6%);
- }
- }
-}
-
-input:not(bit-form-field input, bit-search input, input[bitcheckbox]),
-select:not(bit-form-field select),
-textarea:not(bit-form-field textarea) {
- @include themify($themes) {
- color: themed("textColor");
- background-color: themed("inputBackgroundColor");
- }
-}
-
-input:not(input[bitcheckbox]),
-select,
-textarea,
-button:not(bit-chip-select button) {
- font-size: $font-size-base;
- font-family: $font-family-sans-serif;
-}
-
-input[type*="date"] {
- @include themify($themes) {
- color-scheme: themed("dateInputColorScheme");
- }
-}
-
-::-webkit-calendar-picker-indicator {
- @include themify($themes) {
- filter: themed("webkitCalendarPickerFilter");
- }
-}
-
-::-webkit-calendar-picker-indicator:hover {
- @include themify($themes) {
- filter: themed("webkitCalendarPickerHoverFilter");
- }
- cursor: pointer;
-}
-
-select {
- width: 100%;
- padding: 0.35rem;
-}
-
-button {
- cursor: pointer;
-}
-
-textarea {
- resize: vertical;
-}
-
-app-root > div {
- height: 100%;
- width: 100%;
-}
-
-main::-webkit-scrollbar,
-cdk-virtual-scroll-viewport::-webkit-scrollbar,
-.vault-select::-webkit-scrollbar {
- width: 10px;
- height: 10px;
-}
-
-main::-webkit-scrollbar-track,
-.vault-select::-webkit-scrollbar-track {
- background-color: transparent;
-}
-
-cdk-virtual-scroll-viewport::-webkit-scrollbar-track {
- @include themify($themes) {
- background-color: themed("backgroundColor");
- }
-}
-
-main::-webkit-scrollbar-thumb,
-cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb,
-.vault-select::-webkit-scrollbar-thumb {
- border-radius: 10px;
- margin-right: 1px;
-
- @include themify($themes) {
- background-color: themed("scrollbarColor");
- }
-
- &:hover {
- @include themify($themes) {
- background-color: themed("scrollbarHoverColor");
- }
- }
-}
-
-header:not(bit-callout header, bit-dialog header, popup-page header) {
- height: 44px;
- display: flex;
-
- &:not(.no-theme) {
- border-bottom: 1px solid #000000;
-
- @include themify($themes) {
- color: themed("headerColor");
- background-color: themed("headerBackgroundColor");
- border-bottom-color: themed("headerBorderColor");
- }
- }
-
- .header-content {
- display: flex;
- flex: 1 1 auto;
- }
-
- .header-content > .right,
- .header-content > .right > .right {
- height: 100%;
- }
-
- .left,
- .right {
- flex: 1;
- display: flex;
- min-width: -webkit-min-content; /* Workaround to Chrome bug */
- .header-icon {
- margin-right: 5px;
- }
- }
-
- .right {
- justify-content: flex-end;
- align-items: center;
- app-avatar {
- max-height: 30px;
- margin-right: 5px;
- }
- }
-
- .center {
- display: flex;
- align-items: center;
- text-align: center;
- min-width: 0;
- }
-
- .login-center {
- margin: auto;
- }
-
- app-pop-out > button,
- div > button:not(app-current-account button):not(.home-acc-switcher-btn),
- div > a {
- border: none;
- padding: 0 10px;
- text-decoration: none;
- display: flex;
- flex-direction: row;
- justify-content: center;
- align-items: center;
- height: 100%;
- white-space: pre;
-
- &:not(.home-acc-switcher-btn):hover,
- &:not(.home-acc-switcher-btn):focus {
- @include themify($themes) {
- background-color: themed("headerBackgroundHoverColor");
- color: themed("headerColor");
- }
- }
-
- &[disabled] {
- opacity: 0.65;
- cursor: default !important;
- background-color: inherit !important;
- }
-
- i + span {
- margin-left: 5px;
- }
- }
-
- app-pop-out {
- display: flex;
- padding-right: 0.5em;
- }
-
- .title {
- font-weight: bold;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .search {
- padding: 7px 10px;
- width: 100%;
- text-align: left;
- position: relative;
- display: flex;
-
- .bwi {
- position: absolute;
- top: 15px;
- left: 20px;
-
- @include themify($themes) {
- color: themed("headerInputPlaceholderColor");
- }
- }
-
- input:not(bit-form-field input) {
- width: 100%;
- margin: 0;
- border: none;
- padding: 5px 10px 5px 30px;
- border-radius: $border-radius;
-
- @include themify($themes) {
- background-color: themed("headerInputBackgroundColor");
- color: themed("headerInputColor");
- }
-
- &::selection {
- @include themify($themes) {
- // explicitly set text selection to invert foreground/background
- background-color: themed("headerInputColor");
- color: themed("headerInputBackgroundColor");
- }
- }
-
- &:focus {
- border-radius: $border-radius;
- outline: none;
-
- @include themify($themes) {
- background-color: themed("headerInputBackgroundFocusColor");
- }
- }
-
- &::-webkit-input-placeholder {
- @include themify($themes) {
- color: themed("headerInputPlaceholderColor");
- }
- }
- /** make the cancel button visible in both dark/light themes **/
- &[type="search"]::-webkit-search-cancel-button {
- -webkit-appearance: none;
- appearance: none;
- height: 15px;
- width: 15px;
- background-repeat: no-repeat;
- mask-image: url("../images/close-button-white.svg");
- -webkit-mask-image: url("../images/close-button-white.svg");
- @include themify($themes) {
- background-color: themed("headerInputColor");
- }
- }
- }
- }
-
- .left + .search,
- .left + .sr-only + .search {
- padding-left: 0;
-
- .bwi {
- left: 10px;
- }
- }
-
- .search + .right {
- margin-left: -10px;
- }
-}
-
-.content {
- padding: 15px 5px;
-}
-
-app-root {
- width: 100%;
- height: 100vh;
- display: flex;
-
- @include themify($themes) {
- background-color: themed("backgroundColor");
- }
-}
-
-main:not(popup-page main):not(auth-anon-layout main) {
- position: absolute;
- top: 44px;
- bottom: 0;
- left: 0;
- right: 0;
- overflow-y: auto;
- overflow-x: hidden;
-
- @include themify($themes) {
- background-color: themed("backgroundColor");
- }
-
- &.no-header {
- top: 0;
- }
-
- &.flex {
- display: flex;
- flex-flow: column;
- height: calc(100% - 44px);
- }
-}
-
-.center-content,
-.no-items,
-.full-loading-spinner {
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100%;
- flex-direction: column;
- flex-grow: 1;
-}
-
-.no-items,
-.full-loading-spinner {
- text-align: center;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
-
- .no-items-image {
- @include themify($themes) {
- content: url("../images/search-desktop" + themed("svgSuffix"));
- }
- }
-
- .bwi {
- margin-bottom: 10px;
-
- @include themify($themes) {
- color: themed("disabledIconColor");
- }
- }
-}
-
-// cdk-virtual-scroll
-.cdk-virtual-scroll-viewport {
- width: 100%;
- height: 100%;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.cdk-virtual-scroll-content-wrapper {
- width: 100%;
-}
diff --git a/apps/browser/src/popup/scss/box.scss b/apps/browser/src/popup/scss/box.scss
deleted file mode 100644
index 763f73a15cb..00000000000
--- a/apps/browser/src/popup/scss/box.scss
+++ /dev/null
@@ -1,620 +0,0 @@
-@import "variables.scss";
-
-.box {
- position: relative;
- width: 100%;
-
- &.first {
- margin-top: 0;
- }
-
- .box-header {
- margin: 0 10px 5px 10px;
- text-transform: uppercase;
- display: flex;
-
- @include themify($themes) {
- color: themed("headingColor");
- }
- }
-
- .box-content {
- @include themify($themes) {
- background-color: themed("backgroundColor");
- border-color: themed("borderColor");
- }
-
- &.box-content-padded {
- padding: 10px 15px;
- }
-
- &.condensed .box-content-row,
- .box-content-row.condensed {
- padding-top: 5px;
- padding-bottom: 5px;
- }
-
- &.no-hover .box-content-row,
- .box-content-row.no-hover {
- &:hover,
- &:focus {
- @include themify($themes) {
- background-color: themed("boxBackgroundColor") !important;
- }
- }
- }
-
- &.single-line .box-content-row,
- .box-content-row.single-line {
- padding-top: 10px;
- padding-bottom: 10px;
- margin: 5px;
- }
-
- &.row-top-padding {
- padding-top: 10px;
- }
- }
-
- .box-footer {
- margin: 0 5px 5px 5px;
- padding: 0 10px 5px 10px;
- font-size: $font-size-small;
-
- button.btn {
- font-size: $font-size-small;
- padding: 0;
- }
-
- button.btn.primary {
- font-size: $font-size-base;
- padding: 7px 15px;
- width: 100%;
-
- &:hover {
- @include themify($themes) {
- border-color: themed("borderHoverColor") !important;
- }
- }
- }
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
- }
-
- &.list {
- margin: 10px 0 20px 0;
- .box-content {
- .virtual-scroll-item {
- display: inline-block;
- width: 100%;
- }
-
- .box-content-row {
- text-decoration: none;
- border-radius: $border-radius;
- // background-color: $background-color;
-
- @include themify($themes) {
- color: themed("textColor");
- background-color: themed("boxBackgroundColor");
- }
-
- &.padded {
- padding-top: 10px;
- padding-bottom: 10px;
- }
-
- &.no-hover {
- &:hover {
- @include themify($themes) {
- background-color: themed("boxBackgroundColor") !important;
- }
- }
- }
-
- &:hover,
- &:focus,
- &.active {
- @include themify($themes) {
- background-color: themed("listItemBackgroundHoverColor");
- }
- }
-
- &:focus {
- border-left: 5px solid #000000;
- padding-left: 5px;
-
- @include themify($themes) {
- border-left-color: themed("mutedColor");
- }
- }
-
- .action-buttons {
- .row-btn {
- padding-left: 5px;
- padding-right: 5px;
- }
- }
-
- .text:not(.no-ellipsis),
- .detail {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .row-main {
- display: flex;
- min-width: 0;
- align-items: normal;
-
- .row-main-content {
- min-width: 0;
- }
- }
- }
-
- &.single-line {
- .box-content-row {
- display: flex;
- padding-top: 10px;
- padding-bottom: 10px;
- margin: 5px;
- border-radius: $border-radius;
- }
- }
- }
- }
-}
-
-.box-content-row {
- display: block;
- padding: 5px 10px;
- position: relative;
- z-index: 1;
- border-radius: $border-radius;
- margin: 3px 5px;
-
- @include themify($themes) {
- background-color: themed("boxBackgroundColor");
- }
-
- &:last-child {
- &:before {
- border: none;
- height: 0;
- }
- }
-
- &.override-last:last-child:before {
- border-bottom: 1px solid #000000;
- @include themify($themes) {
- border-bottom-color: themed("boxBorderColor");
- }
- }
-
- &.last:last-child:before {
- border-bottom: 1px solid #000000;
- @include themify($themes) {
- border-bottom-color: themed("boxBorderColor");
- }
- }
-
- &:after {
- content: "";
- display: table;
- clear: both;
- }
-
- &:hover,
- &:focus,
- &.active {
- @include themify($themes) {
- background-color: themed("boxBackgroundHoverColor");
- }
- }
-
- &.pre {
- white-space: pre;
- overflow-x: auto;
- }
-
- &.pre-wrap {
- white-space: pre-wrap;
- overflow-x: auto;
- }
-
- .row-label,
- label {
- font-size: $font-size-small;
- display: block;
- width: 100%;
- margin-bottom: 5px;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
-
- .sub-label {
- margin-left: 10px;
- }
- }
-
- .flex-label {
- font-size: $font-size-small;
- display: flex;
- flex-grow: 1;
- margin-bottom: 5px;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
-
- > a {
- flex-grow: 0;
- }
- }
-
- .text,
- .detail {
- display: block;
- text-align: left;
-
- @include themify($themes) {
- color: themed("textColor");
- }
- }
-
- .detail {
- font-size: $font-size-small;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
- }
-
- .img-right,
- .txt-right {
- float: right;
- margin-left: 10px;
- }
-
- .row-main {
- flex-grow: 1;
- min-width: 0;
- }
-
- &.box-content-row-flex,
- .box-content-row-flex,
- &.box-content-row-checkbox,
- &.box-content-row-link,
- &.box-content-row-input,
- &.box-content-row-slider,
- &.box-content-row-multi {
- display: flex;
- align-items: center;
- word-break: break-all;
-
- &.box-content-row-word-break {
- word-break: normal;
- }
- }
-
- &.box-content-row-multi {
- input:not([type="checkbox"]) {
- width: 100%;
- }
-
- input + label.sr-only + select {
- margin-top: 5px;
- }
-
- > a,
- > button {
- padding: 8px 8px 8px 4px;
- margin: 0;
-
- @include themify($themes) {
- color: themed("dangerColor");
- }
- }
- }
-
- &.box-content-row-multi,
- &.box-content-row-newmulti {
- padding-left: 10px;
- }
-
- &.box-content-row-newmulti {
- @include themify($themes) {
- color: themed("primaryColor");
- }
- }
-
- &.box-content-row-checkbox,
- &.box-content-row-link,
- &.box-content-row-input,
- &.box-content-row-slider {
- padding-top: 10px;
- padding-bottom: 10px;
- margin: 5px;
-
- label,
- .row-label {
- font-size: $font-size-base;
- display: block;
- width: initial;
- margin-bottom: 0;
-
- @include themify($themes) {
- color: themed("textColor");
- }
- }
-
- > span {
- @include themify($themes) {
- color: themed("mutedColor");
- }
- }
-
- > input {
- margin: 0 0 0 auto;
- padding: 0;
- }
-
- > * {
- margin-right: 15px;
-
- &:last-child {
- margin-right: 0;
- }
- }
- }
-
- &.box-content-row-checkbox-left {
- justify-content: flex-start;
-
- > input {
- margin: 0 15px 0 0;
- }
- }
-
- &.box-content-row-input {
- label {
- white-space: nowrap;
- }
-
- input {
- text-align: right;
-
- &[type="number"] {
- max-width: 50px;
- }
- }
- }
-
- &.box-content-row-slider {
- input[type="range"] {
- height: 10px;
- }
-
- input[type="number"] {
- width: 45px;
- }
-
- label {
- white-space: nowrap;
- }
- }
-
- input:not([type="checkbox"]):not([type="radio"]),
- textarea {
- border: none;
- width: 100%;
- background-color: transparent !important;
-
- &::-webkit-input-placeholder {
- @include themify($themes) {
- color: themed("inputPlaceholderColor");
- }
- }
-
- &:not([type="file"]):focus {
- outline: none;
- }
- }
-
- select {
- width: 100%;
- border: 1px solid #000000;
- border-radius: $border-radius;
- padding: 7px 4px;
-
- @include themify($themes) {
- border-color: themed("inputBorderColor");
- }
- }
-
- .action-buttons {
- display: flex;
- margin-left: 5px;
-
- &.action-buttons-fixed {
- align-self: start;
- margin-top: 2px;
- }
-
- .row-btn {
- cursor: pointer;
- padding: 10px 8px;
- background: none;
- border: none;
-
- @include themify($themes) {
- color: themed("boxRowButtonColor");
- }
-
- &:hover,
- &:focus {
- @include themify($themes) {
- color: themed("boxRowButtonHoverColor");
- }
- }
-
- &.disabled,
- &[disabled] {
- @include themify($themes) {
- color: themed("disabledIconColor");
- opacity: themed("disabledBoxOpacity");
- }
-
- &:hover {
- @include themify($themes) {
- color: themed("disabledIconColor");
- opacity: themed("disabledBoxOpacity");
- }
- }
- cursor: default !important;
- }
- }
-
- &.no-pad .row-btn {
- padding-top: 0;
- padding-bottom: 0;
- }
- }
-
- &:not(.box-draggable-row) {
- .action-buttons .row-btn:last-child {
- margin-right: -3px;
- }
- }
-
- &.box-draggable-row {
- &.box-content-row-checkbox {
- input[type="checkbox"] + .drag-handle {
- margin-left: 10px;
- }
- }
- }
-
- .drag-handle {
- cursor: move;
- padding: 10px 2px 10px 8px;
- user-select: none;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
- }
-
- &.cdk-drag-preview {
- position: relative;
- display: flex;
- align-items: center;
- opacity: 0.8;
-
- @include themify($themes) {
- background-color: themed("boxBackgroundColor");
- }
- }
-
- select.field-type {
- margin: 5px 0 0 25px;
- width: calc(100% - 25px);
- }
-
- .icon {
- display: flex;
- justify-content: center;
- align-items: center;
- min-width: 34px;
- margin-left: -5px;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
-
- &.icon-small {
- min-width: 25px;
- }
-
- img {
- border-radius: $border-radius;
- max-height: 20px;
- max-width: 20px;
- }
- }
-
- .progress {
- display: flex;
- height: 5px;
- overflow: hidden;
- margin: 5px -15px -10px;
-
- .progress-bar {
- display: flex;
- flex-direction: column;
- justify-content: center;
- white-space: nowrap;
- background-color: $brand-primary;
- }
- }
-
- .radio-group {
- display: flex;
- justify-content: flex-start;
- align-items: center;
- margin-bottom: 5px;
-
- input {
- flex-grow: 0;
- }
-
- label {
- margin: 0 0 0 5px;
- flex-grow: 1;
- font-size: $font-size-base;
- display: block;
- width: 100%;
-
- @include themify($themes) {
- color: themed("textColor");
- }
- }
-
- &.align-start {
- align-items: start;
- margin-top: 10px;
-
- label {
- margin-top: -4px;
- }
- }
- }
-}
-
-.truncate {
- display: inline-block;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-form {
- .box {
- .box-content {
- .box-content-row {
- &.no-hover {
- &:hover {
- @include themify($themes) {
- background-color: themed("transparentColor") !important;
- }
- }
- }
- }
- }
- }
-}
diff --git a/apps/browser/src/popup/scss/buttons.scss b/apps/browser/src/popup/scss/buttons.scss
deleted file mode 100644
index e9af536dc3d..00000000000
--- a/apps/browser/src/popup/scss/buttons.scss
+++ /dev/null
@@ -1,118 +0,0 @@
-@import "variables.scss";
-
-.btn {
- border-radius: $border-radius;
- padding: 7px 15px;
- border: 1px solid #000000;
- font-size: $font-size-base;
- text-align: center;
- cursor: pointer;
-
- @include themify($themes) {
- background-color: themed("buttonBackgroundColor");
- border-color: themed("buttonBorderColor");
- color: themed("buttonColor");
- }
-
- &.primary {
- @include themify($themes) {
- color: themed("buttonPrimaryColor");
- }
- }
-
- &.danger {
- @include themify($themes) {
- color: themed("buttonDangerColor");
- }
- }
-
- &.callout-half {
- font-weight: bold;
- max-width: 50%;
- }
-
- &:hover:not([disabled]) {
- cursor: pointer;
-
- @include themify($themes) {
- background-color: darken(themed("buttonBackgroundColor"), 1.5%);
- border-color: darken(themed("buttonBorderColor"), 17%);
- color: darken(themed("buttonColor"), 10%);
- }
-
- &.primary {
- @include themify($themes) {
- color: darken(themed("buttonPrimaryColor"), 6%);
- }
- }
-
- &.danger {
- @include themify($themes) {
- color: darken(themed("buttonDangerColor"), 6%);
- }
- }
- }
-
- &:focus:not([disabled]) {
- cursor: pointer;
- outline: 0;
-
- @include themify($themes) {
- background-color: darken(themed("buttonBackgroundColor"), 6%);
- border-color: darken(themed("buttonBorderColor"), 25%);
- }
- }
-
- &[disabled] {
- opacity: 0.65;
- cursor: default !important;
- }
-
- &.block {
- display: block;
- width: calc(100% - 10px);
- margin: 0 auto;
- }
-
- &.link,
- &.neutral {
- border: none !important;
- background: none !important;
-
- &:focus {
- text-decoration: underline;
- }
- }
-}
-
-.action-buttons {
- .btn {
- &:focus {
- outline: auto;
- }
- }
-}
-
-button.box-content-row {
- display: block;
- width: calc(100% - 10px);
- text-align: left;
- border-color: none;
-
- @include themify($themes) {
- background-color: themed("boxBackgroundColor");
- }
-}
-
-button {
- border: none;
- background: transparent;
- color: inherit;
-}
-
-.login-buttons {
- .btn.block {
- width: 100%;
- margin-bottom: 10px;
- }
-}
diff --git a/apps/browser/src/popup/scss/environment.scss b/apps/browser/src/popup/scss/environment.scss
deleted file mode 100644
index cd8f6379e2c..00000000000
--- a/apps/browser/src/popup/scss/environment.scss
+++ /dev/null
@@ -1,43 +0,0 @@
-@import "variables.scss";
-
-html.browser_safari {
- &.safari_height_fix {
- body {
- height: 360px !important;
-
- &.body-xs {
- height: 300px !important;
- }
-
- &.body-full {
- height: 100% !important;
- }
- }
- }
-
- header {
- .search .bwi {
- left: 20px;
- }
-
- .left + .search .bwi {
- left: 10px;
- }
- }
-
- .content {
- &.login-page {
- padding-top: 100px;
- }
- }
-
- app-root {
- border-width: 1px;
- border-style: solid;
- border-color: #000000;
- }
-
- &.theme_light app-root {
- border-color: #777777;
- }
-}
diff --git a/apps/browser/src/popup/scss/grid.scss b/apps/browser/src/popup/scss/grid.scss
deleted file mode 100644
index 8cdb29bb52c..00000000000
--- a/apps/browser/src/popup/scss/grid.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-.row {
- display: flex;
- margin: 0 -15px;
- width: 100%;
-}
-
-.col {
- flex-basis: 0;
- flex-grow: 1;
- padding: 0 15px;
-}
diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss
deleted file mode 100644
index 006e1d35f6a..00000000000
--- a/apps/browser/src/popup/scss/misc.scss
+++ /dev/null
@@ -1,348 +0,0 @@
-@import "variables.scss";
-
-small,
-.small {
- font-size: $font-size-small;
-}
-
-.bg-primary {
- @include themify($themes) {
- background-color: themed("primaryColor") !important;
- }
-}
-
-.bg-success {
- @include themify($themes) {
- background-color: themed("successColor") !important;
- }
-}
-
-.bg-danger {
- @include themify($themes) {
- background-color: themed("dangerColor") !important;
- }
-}
-
-.bg-info {
- @include themify($themes) {
- background-color: themed("infoColor") !important;
- }
-}
-
-.bg-warning {
- @include themify($themes) {
- background-color: themed("warningColor") !important;
- }
-}
-
-.text-primary {
- @include themify($themes) {
- color: themed("primaryColor") !important;
- }
-}
-
-.text-success {
- @include themify($themes) {
- color: themed("successColor") !important;
- }
-}
-
-.text-muted {
- @include themify($themes) {
- color: themed("mutedColor") !important;
- }
-}
-
-.text-default {
- @include themify($themes) {
- color: themed("textColor") !important;
- }
-}
-
-.text-danger {
- @include themify($themes) {
- color: themed("dangerColor") !important;
- }
-}
-
-.text-info {
- @include themify($themes) {
- color: themed("infoColor") !important;
- }
-}
-
-.text-warning {
- @include themify($themes) {
- color: themed("warningColor") !important;
- }
-}
-
-.text-center {
- text-align: center;
-}
-
-.font-weight-semibold {
- font-weight: 600;
-}
-
-p.lead {
- font-size: $font-size-large;
- margin-bottom: 20px;
- font-weight: normal;
-}
-
-.flex-right {
- margin-left: auto;
-}
-
-.flex-bottom {
- margin-top: auto;
-}
-
-.no-margin {
- margin: 0 !important;
-}
-
-.display-block {
- display: block !important;
-}
-
-.monospaced {
- font-family: $font-family-monospace;
-}
-
-.show-whitespace {
- white-space: pre-wrap;
-}
-
-.img-responsive {
- display: block;
- max-width: 100%;
- height: auto;
-}
-
-.img-rounded {
- border-radius: $border-radius;
-}
-
-.select-index-top {
- position: relative;
- z-index: 100;
-}
-
-.sr-only {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- border: 0 !important;
-}
-
-:not(:focus) > .exists-only-on-parent-focus {
- display: none;
-}
-
-.password-wrapper {
- overflow-wrap: break-word;
- white-space: pre-wrap;
- min-width: 0;
-}
-
-.password-number {
- @include themify($themes) {
- color: themed("passwordNumberColor");
- }
-}
-
-.password-special {
- @include themify($themes) {
- color: themed("passwordSpecialColor");
- }
-}
-
-.password-character {
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- width: 30px;
- height: 36px;
- font-weight: 600;
-
- &:nth-child(odd) {
- @include themify($themes) {
- background-color: themed("backgroundColor");
- }
- }
-}
-
-.password-count {
- white-space: nowrap;
- font-size: 8px;
-
- @include themify($themes) {
- color: themed("passwordCountText") !important;
- }
-}
-
-#duo-frame {
- background: url("../images/loading.svg") 0 0 no-repeat;
- width: 100%;
- height: 470px;
- margin-bottom: -10px;
-
- iframe {
- width: 100%;
- height: 100%;
- border: none;
- }
-}
-
-#web-authn-frame {
- width: 100%;
- height: 40px;
-
- iframe {
- border: none;
- height: 100%;
- width: 100%;
- }
-}
-
-body.linux-webauthn {
- width: 485px !important;
- #web-authn-frame {
- iframe {
- width: 375px;
- margin: 0 55px;
- }
- }
-}
-
-app-root > #loading {
- display: flex;
- text-align: center;
- justify-content: center;
- align-items: center;
- height: 100%;
- width: 100%;
- color: $text-muted;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
-}
-
-app-vault-icon,
-.app-vault-icon {
- display: flex;
-}
-
-.logo-image {
- margin: 0 auto;
- width: 142px;
- height: 21px;
- background-size: 142px 21px;
- background-repeat: no-repeat;
- @include themify($themes) {
- background-image: url("../images/logo-" + themed("logoSuffix") + "@2x.png");
- }
- @media (min-width: 219px) {
- width: 189px;
- height: 28px;
- background-size: 189px 28px;
- }
- @media (min-width: 314px) {
- width: 284px;
- height: 43px;
- background-size: 284px 43px;
- }
-}
-
-[hidden] {
- display: none !important;
-}
-
-.draggable {
- cursor: move;
-}
-
-input[type="password"]::-ms-reveal {
- display: none;
-}
-
-.flex {
- display: flex;
-
- &.flex-grow {
- > * {
- flex: 1;
- }
- }
-}
-
-// Text selection styles
-// Set explicit selection styles (assumes primary accent color has sufficient
-// contrast against the background, so its inversion is also still readable)
-// and suppress user selection for most elements (to make it more app-like)
-
-:not(bit-form-field input)::selection {
- @include themify($themes) {
- color: themed("backgroundColor");
- background-color: themed("primaryAccentColor");
- }
-}
-
-h1,
-h2,
-h3,
-label,
-a,
-button,
-p,
-img,
-.box-header,
-.box-footer,
-.callout,
-.row-label,
-.modal-title,
-.overlay-container {
- user-select: none;
-
- &.user-select {
- user-select: auto;
- }
-}
-
-/* tweak for inconsistent line heights in cipher view */
-.box-footer button,
-.box-footer a {
- line-height: 1;
-}
-
-// Workaround for slow performance on external monitors on Chrome + MacOS
-// See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
-@keyframes redraw {
- 0% {
- opacity: 0.99;
- }
- 100% {
- opacity: 1;
- }
-}
-html.force_redraw {
- animation: redraw 1s linear infinite;
-}
-
-/* override for vault icon in browser (pre extension refresh) */
-app-vault-icon:not(app-vault-list-items-container app-vault-icon) > div {
- display: flex;
- justify-content: center;
- align-items: center;
- float: left;
- height: 36px;
- width: 34px;
- margin-left: -5px;
-}
diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss
deleted file mode 100644
index 56c5f80c86c..00000000000
--- a/apps/browser/src/popup/scss/pages.scss
+++ /dev/null
@@ -1,144 +0,0 @@
-@import "variables.scss";
-
-app-home {
- position: fixed;
- height: 100%;
- width: 100%;
-
- .center-content {
- margin-top: -50px;
- height: calc(100% + 50px);
- }
-
- img {
- width: 284px;
- margin: 0 auto;
- }
-
- p.lead {
- margin: 30px 0;
- }
-
- .btn + .btn {
- margin-top: 10px;
- }
-
- button.settings-icon {
- position: absolute;
- top: 10px;
- left: 10px;
-
- @include themify($themes) {
- color: themed("mutedColor");
- }
-
- &:not(:hover):not(:focus) {
- span {
- clip: rect(0 0 0 0);
- clip-path: inset(50%);
- height: 1px;
- overflow: hidden;
- position: absolute;
- white-space: nowrap;
- width: 1px;
- }
- }
-
- &:hover,
- &:focus {
- text-decoration: none;
-
- @include themify($themes) {
- color: themed("primaryColor");
- }
- }
- }
-}
-
-body.body-sm,
-body.body-xs {
- app-home {
- .center-content {
- margin-top: 0;
- height: 100%;
- }
-
- p.lead {
- margin: 15px 0;
- }
- }
-}
-
-body.body-full {
- app-home {
- .center-content {
- margin-top: -80px;
- height: calc(100% + 80px);
- }
- }
-}
-
-.createAccountLink {
- padding: 30px 10px 0 10px;
-}
-
-.remember-email-check {
- padding-top: 18px;
- padding-left: 10px;
- padding-bottom: 18px;
-}
-
-.login-buttons > button {
- margin: 15px 0 15px 0;
-}
-
-.useBrowserlink {
- margin-left: 5px;
- margin-top: 20px;
-
- span {
- font-weight: 700;
- font-size: $font-size-small;
- }
-}
-
-.fido2-browser-selector-dropdown {
- @include themify($themes) {
- background-color: themed("boxBackgroundColor");
- }
- padding: 8px;
- width: 100%;
- box-shadow:
- 0 2px 2px 0 rgba(0, 0, 0, 0.14),
- 0 3px 1px -2px rgba(0, 0, 0, 0.12),
- 0 1px 5px 0 rgba(0, 0, 0, 0.2);
- border-radius: $border-radius;
-}
-
-.fido2-browser-selector-dropdown-item {
- @include themify($themes) {
- color: themed("textColor") !important;
- }
- width: 100%;
- text-align: left;
- padding: 0px 15px 0px 5px;
- margin-bottom: 5px;
- border-radius: 3px;
- border: 1px solid transparent;
- transition: all 0.2s ease-in-out;
-
- &:hover {
- @include themify($themes) {
- background-color: themed("listItemBackgroundHoverColor") !important;
- }
- }
-
- &:last-child {
- margin-bottom: 0;
- }
-}
-
-/** Temporary fix for avatar, will not be required once we migrate to tailwind preflight **/
-bit-avatar svg {
- display: block;
-}
diff --git a/apps/browser/src/popup/scss/plugins.scss b/apps/browser/src/popup/scss/plugins.scss
deleted file mode 100644
index 591e8a1bd0c..00000000000
--- a/apps/browser/src/popup/scss/plugins.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-@import "variables.scss";
-
-@each $mfaType in $mfaTypes {
- .mfaType#{$mfaType} {
- content: url("../images/two-factor/" + $mfaType + ".png");
- max-width: 100px;
- }
-}
-
-.mfaType1 {
- @include themify($themes) {
- content: url("../images/two-factor/1" + themed("mfaLogoSuffix"));
- max-width: 100px;
- max-height: 45px;
- }
-}
-
-.mfaType7 {
- @include themify($themes) {
- content: url("../images/two-factor/7" + themed("mfaLogoSuffix"));
- max-width: 100px;
- }
-}
diff --git a/apps/browser/src/popup/scss/popup.scss b/apps/browser/src/popup/scss/popup.scss
index b150de2c75d..59b4d472f23 100644
--- a/apps/browser/src/popup/scss/popup.scss
+++ b/apps/browser/src/popup/scss/popup.scss
@@ -1,13 +1,50 @@
@import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss";
@import "variables.scss";
@import "../../../../../libs/angular/src/scss/icons.scss";
-@import "base.scss";
-@import "grid.scss";
-@import "box.scss";
-@import "buttons.scss";
-@import "misc.scss";
-@import "environment.scss";
-@import "pages.scss";
-@import "plugins.scss";
@import "@angular/cdk/overlay-prebuilt.css";
@import "../../../../../libs/components/src/multi-select/scss/bw.theme";
+
+.cdk-virtual-scroll-content-wrapper {
+ width: 100%;
+}
+
+// MFA Types for logo styling with no dark theme alternative
+$mfaTypes: 0, 2, 3, 4, 6;
+
+@each $mfaType in $mfaTypes {
+ .mfaType#{$mfaType} {
+ content: url("../images/two-factor/" + $mfaType + ".png");
+ max-width: 100px;
+ }
+}
+
+.mfaType0 {
+ content: url("../images/two-factor/0.png");
+ max-width: 100px;
+ max-height: 45px;
+}
+
+.mfaType1 {
+ max-width: 100px;
+ max-height: 45px;
+
+ &:is(.theme_light *) {
+ content: url("../images/two-factor/1.png");
+ }
+
+ &:is(.theme_dark *) {
+ content: url("../images/two-factor/1-w.png");
+ }
+}
+
+.mfaType7 {
+ max-width: 100px;
+
+ &:is(.theme_light *) {
+ content: url("../images/two-factor/7.png");
+ }
+
+ &:is(.theme_dark *) {
+ content: url("../images/two-factor/7-w.png");
+ }
+}
diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css
index 54139990356..f58950cc86a 100644
--- a/apps/browser/src/popup/scss/tailwind.css
+++ b/apps/browser/src/popup/scss/tailwind.css
@@ -1,4 +1,104 @@
-@import "../../../../../libs/components/src/tw-theme.css";
+@import "../../../../../libs/components/src/tw-theme-preflight.css";
+
+@layer base {
+ html {
+ overflow: hidden;
+ min-height: 600px;
+ height: 100%;
+
+ &.body-sm {
+ min-height: 500px;
+ }
+
+ &.body-xs {
+ min-height: 400px;
+ }
+
+ &.body-xxs {
+ min-height: 300px;
+ }
+
+ &.body-3xs {
+ min-height: 240px;
+ }
+
+ &.body-full {
+ min-height: unset;
+ width: 100%;
+ height: 100%;
+
+ & body {
+ width: 100%;
+ }
+ }
+ }
+
+ html.browser_safari {
+ &.safari_height_fix {
+ body {
+ height: 360px !important;
+
+ &.body-xs {
+ height: 300px !important;
+ }
+
+ &.body-full {
+ height: 100% !important;
+ }
+ }
+ }
+
+ app-root {
+ border-width: 1px;
+ border-style: solid;
+ border-color: #000000;
+ }
+
+ &.theme_light app-root {
+ border-color: #777777;
+ }
+ }
+
+ body {
+ width: 380px;
+ height: 100%;
+ position: relative;
+ min-height: inherit;
+ overflow: hidden;
+ @apply tw-bg-background-alt;
+ }
+
+ /**
+ * Workaround for slow performance on external monitors on Chrome + MacOS
+ * See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
+ */
+ @keyframes redraw {
+ 0% {
+ opacity: 0.99;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+ html.force_redraw {
+ animation: redraw 1s linear infinite;
+ }
+
+ /**
+ * Text selection style:
+ * suppress user selection for most elements (to make it more app-like)
+ */
+ h1,
+ h2,
+ h3,
+ label,
+ a,
+ button,
+ p,
+ img {
+ user-select: none;
+ }
+}
@layer components {
/** Safari Support */
@@ -19,4 +119,59 @@
html:not(.browser_safari) .tw-styled-scrollbar {
scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt));
}
+
+ #duo-frame {
+ background: url("../images/loading.svg") 0 0 no-repeat;
+ width: 100%;
+ height: 470px;
+ margin-bottom: -10px;
+
+ iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+ }
+
+ #web-authn-frame {
+ width: 100%;
+ height: 40px;
+
+ iframe {
+ border: none;
+ height: 100%;
+ width: 100%;
+ }
+ }
+
+ body.linux-webauthn {
+ width: 485px !important;
+ #web-authn-frame {
+ iframe {
+ width: 375px;
+ margin: 0 55px;
+ }
+ }
+ }
+
+ app-root > #loading {
+ display: flex;
+ text-align: center;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+
+ @apply tw-text-muted;
+ }
+
+ /**
+ * Text selection style:
+ * Set explicit selection styles (assumes primary accent color has sufficient
+ * contrast against the background, so its inversion is also still readable)
+ */
+ :not(bit-form-field input)::selection {
+ @apply tw-text-contrast;
+ @apply tw-bg-primary-700;
+ }
}
diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss
index e57e98fd0cc..02a10521bca 100644
--- a/apps/browser/src/popup/scss/variables.scss
+++ b/apps/browser/src/popup/scss/variables.scss
@@ -1,178 +1,42 @@
-$dark-icon-themes: "theme_dark";
+/**
+ * DEPRECATED: DO NOT MODIFY OR USE!
+ */
+
+$dark-icon-themes: "theme_dark";
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
-$font-size-base: 16px;
-$font-size-large: 18px;
-$font-size-xlarge: 22px;
-$font-size-xxlarge: 28px;
-$font-size-small: 12px;
$text-color: #000000;
-$border-color: #f0f0f0;
$border-color-dark: #ddd;
-$list-item-hover: #fbfbfb;
-$list-icon-color: #767679;
-$disabled-box-opacity: 1;
-$border-radius: 6px;
-$line-height-base: 1.42857143;
-$icon-hover-color: lighten($text-color, 50%);
-
-$mfaTypes: 0, 2, 3, 4, 6;
-
-$gray: #555;
-$gray-light: #777;
-$text-muted: $gray-light;
-
$brand-primary: #175ddc;
-$brand-danger: #c83522;
$brand-success: #017e45;
-$brand-info: #555555;
-$brand-warning: #8b6609;
-$brand-primary-accent: #1252a3;
-
$background-color: #f0f0f0;
-
-$box-background-color: white;
-$box-background-hover-color: $list-item-hover;
-$box-border-color: $border-color;
-$border-color-alt: #c3c5c7;
-
-$button-border-color: darken($border-color-dark, 12%);
-$button-background-color: white;
-$button-color: lighten($text-color, 40%);
$button-color-primary: darken($brand-primary, 8%);
-$button-color-danger: darken($brand-danger, 10%);
-
-$code-color: #c01176;
-$code-color-dark: #f08dc7;
$themes: (
light: (
textColor: $text-color,
- hoverColorTransparent: rgba($text-color, 0.15),
borderColor: $border-color-dark,
backgroundColor: $background-color,
- borderColorAlt: $border-color-alt,
- backgroundColorAlt: #ffffff,
- scrollbarColor: rgba(100, 100, 100, 0.2),
- scrollbarHoverColor: rgba(100, 100, 100, 0.4),
- boxBackgroundColor: $box-background-color,
- boxBackgroundHoverColor: $box-background-hover-color,
- boxBorderColor: $box-border-color,
- tabBackgroundColor: #ffffff,
- tabBackgroundHoverColor: $list-item-hover,
- headerColor: #ffffff,
- headerBackgroundColor: $brand-primary,
- headerBackgroundHoverColor: rgba(255, 255, 255, 0.1),
- headerBorderColor: $brand-primary,
- headerInputBackgroundColor: darken($brand-primary, 8%),
- headerInputBackgroundFocusColor: darken($brand-primary, 10%),
- headerInputColor: #ffffff,
- headerInputPlaceholderColor: lighten($brand-primary, 35%),
- listItemBackgroundHoverColor: $list-item-hover,
- disabledIconColor: $list-icon-color,
- disabledBoxOpacity: $disabled-box-opacity,
- headingColor: $gray-light,
- labelColor: $gray-light,
- mutedColor: $text-muted,
- totpStrokeColor: $brand-primary,
- boxRowButtonColor: $brand-primary,
- boxRowButtonHoverColor: darken($brand-primary, 10%),
inputBorderColor: darken($border-color-dark, 7%),
inputBackgroundColor: #ffffff,
- inputPlaceholderColor: lighten($gray-light, 35%),
- buttonBackgroundColor: $button-background-color,
- buttonBorderColor: $button-border-color,
- buttonColor: $button-color,
buttonPrimaryColor: $button-color-primary,
- buttonDangerColor: $button-color-danger,
primaryColor: $brand-primary,
- primaryAccentColor: $brand-primary-accent,
- dangerColor: $brand-danger,
successColor: $brand-success,
- infoColor: $brand-info,
- warningColor: $brand-warning,
- logoSuffix: "dark",
- mfaLogoSuffix: ".png",
passwordNumberColor: #007fde,
passwordSpecialColor: #c40800,
- passwordCountText: #212529,
- calloutBorderColor: $border-color-dark,
- calloutBackgroundColor: $box-background-color,
- toastTextColor: #ffffff,
- svgSuffix: "-light.svg",
- transparentColor: rgba(0, 0, 0, 0),
- dateInputColorScheme: light,
- // https://stackoverflow.com/a/53336754
- webkitCalendarPickerFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
- brightness(85%) contrast(103%),
- // light has no hover so use same color
- webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
- brightness(85%) contrast(103%),
- codeColor: $code-color,
),
dark: (
textColor: #ffffff,
- hoverColorTransparent: rgba($text-color, 0.15),
borderColor: #161c26,
backgroundColor: #161c26,
- borderColorAlt: #6e788a,
- backgroundColorAlt: #2f343d,
- scrollbarColor: #6e788a,
- scrollbarHoverColor: #8d94a5,
- boxBackgroundColor: #2f343d,
- boxBackgroundHoverColor: #3c424e,
- boxBorderColor: #4c525f,
- tabBackgroundColor: #2f343d,
- tabBackgroundHoverColor: #3c424e,
- headerColor: #ffffff,
- headerBackgroundColor: #2f343d,
- headerBackgroundHoverColor: #3c424e,
- headerBorderColor: #161c26,
- headerInputBackgroundColor: #3c424e,
- headerInputBackgroundFocusColor: #4c525f,
- headerInputColor: #ffffff,
- headerInputPlaceholderColor: #bac0ce,
- listItemBackgroundHoverColor: #3c424e,
- disabledIconColor: #bac0ce,
- disabledBoxOpacity: 0.5,
- headingColor: #bac0ce,
- labelColor: #bac0ce,
- mutedColor: #bac0ce,
- totpStrokeColor: #4c525f,
- boxRowButtonColor: #bac0ce,
- boxRowButtonHoverColor: #ffffff,
inputBorderColor: #4c525f,
inputBackgroundColor: #2f343d,
- inputPlaceholderColor: #bac0ce,
- buttonBackgroundColor: #3c424e,
- buttonBorderColor: #4c525f,
- buttonColor: #bac0ce,
buttonPrimaryColor: #6f9df1,
- buttonDangerColor: #ff8d85,
primaryColor: #6f9df1,
- primaryAccentColor: #6f9df1,
- dangerColor: #ff8d85,
successColor: #52e07c,
- infoColor: #a4b0c6,
- warningColor: #ffeb66,
- logoSuffix: "white",
- mfaLogoSuffix: "-w.png",
passwordNumberColor: #6f9df1,
passwordSpecialColor: #ff8d85,
- passwordCountText: #ffffff,
- calloutBorderColor: #4c525f,
- calloutBackgroundColor: #3c424e,
- toastTextColor: #1f242e,
- svgSuffix: "-dark.svg",
- transparentColor: rgba(0, 0, 0, 0),
- dateInputColorScheme: dark,
- // https://stackoverflow.com/a/53336754 - must prepend brightness(0) saturate(100%) to dark themed date inputs
- webkitCalendarPickerFilter: brightness(0) saturate(100%) invert(86%) sepia(19%) saturate(152%)
- hue-rotate(184deg) brightness(87%) contrast(93%),
- webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%)
- saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%),
- codeColor: $code-color-dark,
),
);
diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js
index 134001bbf13..faaa7fa4128 100644
--- a/apps/browser/tailwind.config.js
+++ b/apps/browser/tailwind.config.js
@@ -12,5 +12,6 @@ config.content = [
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/pricing/src/**/*.{html,ts}",
];
+config.corePlugins.preflight = true;
module.exports = config;
From d95dd709b1cd6cb7d6cac1d177cf3d2b48947092 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 14:44:13 -0600
Subject: [PATCH 10/60] [deps]: Update Rust crate thiserror to v2.0.17 (#17574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 16 ++++++++--------
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 7b763e274b4..8cf64c9ea76 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -877,7 +877,7 @@ dependencies = [
"sha2",
"ssh-key",
"sysinfo",
- "thiserror 2.0.12",
+ "thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
@@ -2648,7 +2648,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
- "thiserror 2.0.12",
+ "thiserror 2.0.17",
]
[[package]]
@@ -2858,7 +2858,7 @@ dependencies = [
"libc",
"rustix 1.0.7",
"rustix-linux-procfs",
- "thiserror 2.0.12",
+ "thiserror 2.0.17",
"windows",
]
@@ -3227,11 +3227,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.12"
+version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
- "thiserror-impl 2.0.12",
+ "thiserror-impl 2.0.17",
]
[[package]]
@@ -3247,9 +3247,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
-version = "2.0.12"
+version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index c1a3e98a0de..d85f35141b8 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -61,7 +61,7 @@ sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.37.2"
-thiserror = "=2.0.12"
+thiserror = "=2.0.17"
tokio = "=1.45.0"
tokio-util = "=0.7.13"
tracing = "=0.1.41"
From 717cf93cc8526e6a8c1aacd5ab7a7669f6fb3856 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 20:53:30 +0000
Subject: [PATCH 11/60] [deps]: Update Rust crate cc to v1.2.49 (#17893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 4 ++--
apps/desktop/desktop_native/objc/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 8cf64c9ea76..8ac04eb9a65 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -556,9 +556,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.48"
+version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a"
+checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"shlex",
diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml
index 5d7174894e7..dd808537c28 100644
--- a/apps/desktop/desktop_native/objc/Cargo.toml
+++ b/apps/desktop/desktop_native/objc/Cargo.toml
@@ -14,7 +14,7 @@ tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.build-dependencies]
-cc = "=1.2.48"
+cc = "=1.2.49"
glob = "=0.3.3"
[lints]
From 22338632be9e6628b166b962f4a776ed0d29328f Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 20:57:23 +0000
Subject: [PATCH 12/60] [deps] Platform: Update Rust crate zbus to v5.12.0
(#17035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 89 ++++++++++++++++++++++++--
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 85 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 8ac04eb9a65..cf946f7f204 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -501,6 +501,12 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -1663,6 +1669,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+[[package]]
+name = "js-sys"
+version = "0.3.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2786,6 +2802,12 @@ dependencies = [
"rustix 1.0.7",
]
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
[[package]]
name = "ryu"
version = "1.0.20"
@@ -3668,6 +3690,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "1.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
[[package]]
name = "valuable"
version = "0.1.1"
@@ -3733,6 +3766,51 @@ dependencies = [
"wit-bindgen-rt",
]
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
+dependencies = [
+ "unicode-ident",
+]
+
[[package]]
name = "wayland-backend"
version = "0.3.10"
@@ -4369,9 +4447,9 @@ dependencies = [
[[package]]
name = "zbus"
-version = "5.11.0"
+version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7"
+checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
@@ -4394,7 +4472,8 @@ dependencies = [
"tokio",
"tracing",
"uds_windows",
- "windows-sys 0.60.2",
+ "uuid",
+ "windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
@@ -4403,9 +4482,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
-version = "5.11.0"
+version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca"
+checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [
"proc-macro-crate",
"proc-macro2",
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index d85f35141b8..59df7ba57fb 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -77,7 +77,7 @@ windows = "=0.61.1"
windows-core = "=0.61.0"
windows-future = "=0.2.0"
windows-registry = "=0.6.1"
-zbus = "=5.11.0"
+zbus = "=5.12.0"
zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0"
From 6dba3ac3772ee7c718d5b56cbe94e4249aa29ec1 Mon Sep 17 00:00:00 2001
From: SmithThe4th
Date: Tue, 9 Dec 2025 17:28:04 -0500
Subject: [PATCH 13/60] [PM-27663] Create VaultItemTransferModalComponent and
confirmation dialogs (#17883)
* Created item transfer dialogs
* Added empty line
---
apps/browser/src/_locales/en/messages.json | 48 ++++++++++++++
apps/desktop/src/locales/en/messages.json | 48 ++++++++++++++
apps/web/src/locales/en/messages.json | 48 ++++++++++++++
.../components/vault-items-transfer/index.ts | 13 ++++
.../leave-confirmation-dialog.component.html | 33 ++++++++++
.../leave-confirmation-dialog.component.ts | 64 +++++++++++++++++++
.../transfer-items-dialog.component.html | 22 +++++++
.../transfer-items-dialog.component.ts | 64 +++++++++++++++++++
libs/vault/src/index.ts | 1 +
9 files changed, 341 insertions(+)
create mode 100644 libs/vault/src/components/vault-items-transfer/index.ts
create mode 100644 libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html
create mode 100644 libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts
create mode 100644 libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html
create mode 100644 libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index a5c204ffc99..2d229092787 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -5937,5 +5937,53 @@
},
"upgrade": {
"message": "Upgrade"
+ },
+ "leaveConfirmationDialogTitle": {
+ "message": "Are you sure you want to leave?"
+ },
+ "leaveConfirmationDialogContentOne": {
+ "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
+ },
+ "leaveConfirmationDialogContentTwo": {
+ "message": "Contact your admin to regain access."
+ },
+ "leaveConfirmationDialogConfirmButton": {
+ "message": "Leave $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "howToManageMyVault": {
+ "message": "How do I manage my vault?"
+ },
+ "transferItemsToOrganizationTitle": {
+ "message": "Transfer items to $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "transferItemsToOrganizationContent": {
+ "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "acceptTransfer": {
+ "message": "Accept transfer"
+ },
+ "declineAndLeave": {
+ "message": "Decline and leave"
+ },
+ "whyAmISeeingThis": {
+ "message": "Why am I seeing this?"
}
}
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 4c3833e28c3..f6a679d9c7c 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -4383,5 +4383,53 @@
},
"upgrade": {
"message": "Upgrade"
+ },
+ "leaveConfirmationDialogTitle": {
+ "message": "Are you sure you want to leave?"
+ },
+ "leaveConfirmationDialogContentOne": {
+ "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
+ },
+ "leaveConfirmationDialogContentTwo": {
+ "message": "Contact your admin to regain access."
+ },
+ "leaveConfirmationDialogConfirmButton": {
+ "message": "Leave $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "howToManageMyVault": {
+ "message": "How do I manage my vault?"
+ },
+ "transferItemsToOrganizationTitle": {
+ "message": "Transfer items to $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "transferItemsToOrganizationContent": {
+ "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "acceptTransfer": {
+ "message": "Accept transfer"
+ },
+ "declineAndLeave": {
+ "message": "Decline and leave"
+ },
+ "whyAmISeeingThis": {
+ "message": "Why am I seeing this?"
}
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 4be70b102d1..bbbf548b8e1 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -12287,5 +12287,53 @@
},
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action"
+ },
+ "leaveConfirmationDialogTitle": {
+ "message": "Are you sure you want to leave?"
+ },
+ "leaveConfirmationDialogContentOne": {
+ "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
+ },
+ "leaveConfirmationDialogContentTwo": {
+ "message": "Contact your admin to regain access."
+ },
+ "leaveConfirmationDialogConfirmButton": {
+ "message": "Leave $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "howToManageMyVault": {
+ "message": "How do I manage my vault?"
+ },
+ "transferItemsToOrganizationTitle": {
+ "message": "Transfer items to $ORGANIZATION$",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "transferItemsToOrganizationContent": {
+ "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
+ "placeholders": {
+ "organization": {
+ "content": "$1",
+ "example": "My Org Name"
+ }
+ }
+ },
+ "acceptTransfer": {
+ "message": "Accept transfer"
+ },
+ "declineAndLeave": {
+ "message": "Decline and leave"
+ },
+ "whyAmISeeingThis": {
+ "message": "Why am I seeing this?"
}
}
diff --git a/libs/vault/src/components/vault-items-transfer/index.ts b/libs/vault/src/components/vault-items-transfer/index.ts
new file mode 100644
index 00000000000..f2ffb9f9c22
--- /dev/null
+++ b/libs/vault/src/components/vault-items-transfer/index.ts
@@ -0,0 +1,13 @@
+export {
+ TransferItemsDialogComponent,
+ TransferItemsDialogParams,
+ TransferItemsDialogResult,
+ TransferItemsDialogResultType,
+} from "./transfer-items-dialog.component";
+
+export {
+ LeaveConfirmationDialogComponent,
+ LeaveConfirmationDialogParams,
+ LeaveConfirmationDialogResult,
+ LeaveConfirmationDialogResultType,
+} from "./leave-confirmation-dialog.component";
diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html
new file mode 100644
index 00000000000..f0d644fecff
--- /dev/null
+++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html
@@ -0,0 +1,33 @@
+
+
+
+ {{ "leaveConfirmationDialogTitle" | i18n }}
+
+
+
+ {{ "leaveConfirmationDialogContentOne" | i18n }}
+
+
+ {{ "leaveConfirmationDialogContentTwo" | i18n }}
+
+
+
+
+
+
+
+
+
+ {{ "howToManageMyVault" | i18n }}
+
+
+
+
diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts
new file mode 100644
index 00000000000..bd32a1ea6dd
--- /dev/null
+++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts
@@ -0,0 +1,64 @@
+import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
+import {
+ DIALOG_DATA,
+ DialogConfig,
+ DialogRef,
+ DialogService,
+ ButtonModule,
+ DialogModule,
+ LinkModule,
+ TypographyModule,
+} from "@bitwarden/components";
+
+export interface LeaveConfirmationDialogParams {
+ organizationName: string;
+}
+
+export const LeaveConfirmationDialogResult = Object.freeze({
+ /**
+ * User confirmed they want to leave the organization.
+ */
+ Confirmed: "confirmed",
+ /**
+ * User chose to go back instead of leaving the organization.
+ */
+ Back: "back",
+} as const);
+
+export type LeaveConfirmationDialogResultType = UnionOfValues;
+
+@Component({
+ templateUrl: "./leave-confirmation-dialog.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
+})
+export class LeaveConfirmationDialogComponent {
+ private readonly params = inject(DIALOG_DATA);
+ private readonly dialogRef = inject(DialogRef);
+ private readonly platformUtilsService = inject(PlatformUtilsService);
+
+ protected readonly organizationName = this.params.organizationName;
+
+ protected confirmLeave() {
+ this.dialogRef.close(LeaveConfirmationDialogResult.Confirmed);
+ }
+
+ protected goBack() {
+ this.dialogRef.close(LeaveConfirmationDialogResult.Back);
+ }
+
+ protected openLearnMore(e: Event) {
+ e.preventDefault();
+ this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/");
+ }
+
+ static open(dialogService: DialogService, config: DialogConfig) {
+ return dialogService.open(LeaveConfirmationDialogComponent, {
+ ...config,
+ });
+ }
+}
diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html
new file mode 100644
index 00000000000..0b77d4ba7d8
--- /dev/null
+++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html
@@ -0,0 +1,22 @@
+
+ {{ "transferItemsToOrganizationTitle" | i18n: organizationName }}
+
+
+ {{ "transferItemsToOrganizationContent" | i18n: organizationName }}
+
+
+
+
+
+
+
+
+ {{ "whyAmISeeingThis" | i18n }}
+
+
+
+
diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts
new file mode 100644
index 00000000000..f28ad2ab3ec
--- /dev/null
+++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts
@@ -0,0 +1,64 @@
+import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
+import {
+ DIALOG_DATA,
+ DialogConfig,
+ DialogRef,
+ DialogService,
+ ButtonModule,
+ DialogModule,
+ LinkModule,
+ TypographyModule,
+} from "@bitwarden/components";
+
+export interface TransferItemsDialogParams {
+ organizationName: string;
+}
+
+export const TransferItemsDialogResult = Object.freeze({
+ /**
+ * User accepted the transfer of items.
+ */
+ Accepted: "accepted",
+ /**
+ * User declined the transfer of items.
+ */
+ Declined: "declined",
+} as const);
+
+export type TransferItemsDialogResultType = UnionOfValues;
+
+@Component({
+ templateUrl: "./transfer-items-dialog.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
+})
+export class TransferItemsDialogComponent {
+ private readonly params = inject(DIALOG_DATA);
+ private readonly dialogRef = inject(DialogRef);
+ private readonly platformUtilsService = inject(PlatformUtilsService);
+
+ protected readonly organizationName = this.params.organizationName;
+
+ protected acceptTransfer() {
+ this.dialogRef.close(TransferItemsDialogResult.Accepted);
+ }
+
+ protected decline() {
+ this.dialogRef.close(TransferItemsDialogResult.Declined);
+ }
+
+ protected openLearnMore(e: Event) {
+ e.preventDefault();
+ this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/");
+ }
+
+ static open(dialogService: DialogService, config: DialogConfig) {
+ return dialogService.open(TransferItemsDialogComponent, {
+ ...config,
+ });
+ }
+}
diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts
index 93a72ba14e0..be0daad3637 100644
--- a/libs/vault/src/index.ts
+++ b/libs/vault/src/index.ts
@@ -29,6 +29,7 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
export * from "./components/carousel";
export * from "./components/new-cipher-menu/new-cipher-menu.component";
export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component";
+export * from "./components/vault-items-transfer";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
From f161a8c454afdaf4d84679fc683157032236fdba Mon Sep 17 00:00:00 2001
From: Shane Melton
Date: Tue, 9 Dec 2025 15:14:40 -0800
Subject: [PATCH 14/60] [PM-27662] Introduce vault item transfer service
(#17876)
* [PM-27662] Add revision date to policy response
* [PM-27662] Introduce vault item transfer service
* [PM-27662] Add feature flag check
* [PM-27662] Add tests
* [PM-27662] Add basic implementation to Web vault
* [PM-27662] Remove redundant for loop
* [PM-27662] Remove unnecessary distinctUntilChanged
* [PM-27662] Avoid subscribing to userMigrationInfo$ if feature flag disabled
* [PM-27662] Make UserMigrationInfo type more strict
* [PM-27662] Typo
* [PM-27662] Fix missing i18n
* [PM-27662] Fix tests
* [PM-27662] Fix tests/types related to policy changes
* [PM-27662] Use getById operator
---
apps/browser/src/_locales/en/messages.json | 3 +
apps/desktop/src/locales/en/messages.json | 3 +
.../vault/individual-vault/vault.component.ts | 9 +-
apps/web/src/locales/en/messages.json | 3 +
.../admin-console/models/data/policy.data.ts | 2 +
.../src/admin-console/models/domain/policy.ts | 3 +
.../models/response/policy.response.ts | 2 +
.../policy/default-policy.service.spec.ts | 40 +
libs/common/src/enums/feature-flag.enum.ts | 2 +
.../available-algorithms-policy.spec.ts | 7 +
.../passphrase-least-privilege.spec.ts | 1 +
.../policies/password-least-privilege.spec.ts | 1 +
.../generator-profile-provider.spec.ts | 1 +
...fault-generator-navigation.service.spec.ts | 1 +
.../src/generator-navigation-policy.spec.ts | 1 +
.../vault-items-transfer.service.ts | 59 ++
libs/vault/src/index.ts | 2 +
...fault-vault-items-transfer.service.spec.ts | 721 ++++++++++++++++++
.../default-vault-items-transfer.service.ts | 231 ++++++
19 files changed, 1090 insertions(+), 2 deletions(-)
create mode 100644 libs/vault/src/abstractions/vault-items-transfer.service.ts
create mode 100644 libs/vault/src/services/default-vault-items-transfer.service.spec.ts
create mode 100644 libs/vault/src/services/default-vault-items-transfer.service.ts
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 2d229092787..a90fbcbf332 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -1475,6 +1475,9 @@
"selectFile": {
"message": "Select a file"
},
+ "itemsTransferred": {
+ "message": "Items transferred"
+ },
"maxFileSize": {
"message": "Maximum file size is 500 MB."
},
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index f6a679d9c7c..7a3abe528e8 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -708,6 +708,9 @@
"addAttachment": {
"message": "Add attachment"
},
+ "itemsTransferred": {
+ "message": "Items transferred"
+ },
"fixEncryption": {
"message": "Fix encryption"
},
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts
index b0685a028df..78a8889bc8f 100644
--- a/apps/web/src/app/vault/individual-vault/vault.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault.component.ts
@@ -84,7 +84,7 @@ import {
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
-import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
+import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
@@ -97,6 +97,8 @@ import {
DecryptionFailureDialogComponent,
DefaultCipherFormConfigService,
PasswordRepromptService,
+ VaultItemsTransferService,
+ DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
@@ -177,12 +179,12 @@ type EmptyStateMap = Record;
VaultItemsModule,
SharedModule,
OrganizationWarningsModule,
- BannerComponent,
],
providers: [
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService,
+ { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
],
})
export class VaultComponent implements OnInit, OnDestroy {
@@ -349,6 +351,7 @@ export class VaultComponent implements OnInit, OnDestr
private premiumUpgradePromptService: PremiumUpgradePromptService,
private autoConfirmService: AutomaticUserConfirmationService,
private configService: ConfigService,
+ private vaultItemTransferService: VaultItemsTransferService,
) {}
async ngOnInit() {
@@ -644,6 +647,8 @@ export class VaultComponent implements OnInit, OnDestr
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
this.setupAutoConfirm();
+
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
}
ngOnDestroy() {
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index bbbf548b8e1..a755e4de556 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5185,6 +5185,9 @@
"oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
},
+ "itemsTransferred": {
+ "message": "Items transferred"
+ },
"yourAccountsFingerprint": {
"message": "Your account's fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."
diff --git a/libs/common/src/admin-console/models/data/policy.data.ts b/libs/common/src/admin-console/models/data/policy.data.ts
index a8628e2f1ab..639fda1fa92 100644
--- a/libs/common/src/admin-console/models/data/policy.data.ts
+++ b/libs/common/src/admin-console/models/data/policy.data.ts
@@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType;
data: Record;
enabled: boolean;
+ revisionDate: string;
constructor(response?: PolicyResponse) {
if (response == null) {
@@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type;
this.data = response.data;
this.enabled = response.enabled;
+ this.revisionDate = response.revisionDate;
}
static fromPolicy(policy: Policy): PolicyData {
diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts
index eb67c4412e9..6b2d587a262 100644
--- a/libs/common/src/admin-console/models/domain/policy.ts
+++ b/libs/common/src/admin-console/models/domain/policy.ts
@@ -19,6 +19,8 @@ export class Policy extends Domain {
*/
enabled: boolean;
+ revisionDate: Date;
+
constructor(obj?: PolicyData) {
super();
if (obj == null) {
@@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type;
this.data = obj.data;
this.enabled = obj.enabled;
+ this.revisionDate = new Date(obj.revisionDate);
}
static fromResponse(response: PolicyResponse): Policy {
diff --git a/libs/common/src/admin-console/models/response/policy.response.ts b/libs/common/src/admin-console/models/response/policy.response.ts
index 0544cd996f4..7cca63a19d3 100644
--- a/libs/common/src/admin-console/models/response/policy.response.ts
+++ b/libs/common/src/admin-console/models/response/policy.response.ts
@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any;
enabled: boolean;
canToggleState: boolean;
+ revisionDate: string;
constructor(response: any) {
super(response);
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
+ this.revisionDate = this.getResponseProperty("RevisionDate");
}
}
diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts
index 4b59683ec0a..2ff649e6533 100644
--- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts
+++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts
@@ -83,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
+ revisionDate: expect.any(Date),
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
});
});
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: false,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
+ data: undefined,
+ revisionDate: expect.any(Date),
},
]);
});
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
+ policyData.revisionDate = new Date().toISOString();
return policyData;
}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 371081a89d9..1727d3da712 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -64,6 +64,7 @@ export enum FeatureFlag {
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
+ MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
+ [FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts
index 5f699974fba..7de8c708dcf 100644
--- a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts
+++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts
@@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override,
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override,
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy, policy]);
@@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "password",
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const passphrase = new Policy({
id: "" as PolicyId,
@@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "passphrase",
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([password, passphrase]);
@@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: false,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
@@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy",
},
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const result = availableAlgorithms([policy]);
diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts
index 0fbc1796e9e..c6ce189f620 100644
--- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts
+++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts
@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
+ revisionDate: new Date().toISOString(),
});
}
diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts
index 7f8dce19b15..7885641c8e5 100644
--- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts
+++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts
@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
+ revisionDate: new Date().toISOString(),
});
}
diff --git a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts
index 32d99aa8a1f..924849b1c22 100644
--- a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts
+++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts
@@ -57,6 +57,7 @@ const somePolicy = new Policy({
id: "" as PolicyId,
organizationId: "" as OrganizationId,
enabled: true,
+ revisionDate: new Date().toISOString(),
});
const stateProvider = new FakeStateProvider(accountService);
diff --git a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts
index 65f1669ebd1..37e8ec6e379 100644
--- a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts
+++ b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts
@@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
enabled: true,
type: PolicyType.PasswordGenerator,
data: { overridePasswordType: "password" },
+ revisionDate: new Date().toISOString(),
}),
]);
},
diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts
index e4f0b08a3d5..69a4e75d47d 100644
--- a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts
+++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts
@@ -17,6 +17,7 @@ function createPolicy(
data,
enabled,
type,
+ revisionDate: new Date().toISOString(),
});
}
diff --git a/libs/vault/src/abstractions/vault-items-transfer.service.ts b/libs/vault/src/abstractions/vault-items-transfer.service.ts
new file mode 100644
index 00000000000..ced9f71eb83
--- /dev/null
+++ b/libs/vault/src/abstractions/vault-items-transfer.service.ts
@@ -0,0 +1,59 @@
+import { Observable } from "rxjs";
+
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
+import { UserId } from "@bitwarden/user-core";
+
+export type UserMigrationInfo =
+ | {
+ /**
+ * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
+ * organizational policy change. (Enforce organization data ownership policy enabled)
+ */
+ requiresMigration: false;
+ }
+ | {
+ /**
+ * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
+ * organizational policy change. (Enforce organization data ownership policy enabled)
+ */
+ requiresMigration: true;
+
+ /**
+ * The organization that is enforcing data ownership policies for the given user.
+ */
+ enforcingOrganization: Organization;
+
+ /**
+ * The default collection ID for the user in the enforcing organization, if available.
+ */
+ defaultCollectionId?: CollectionId;
+ };
+
+export abstract class VaultItemsTransferService {
+ /**
+ * Gets information about whether the given user requires migration of their vault items
+ * from My Vault to a My Items collection, and whether they are capable of performing that migration.
+ * @param userId
+ */
+ abstract userMigrationInfo$(userId: UserId): Observable;
+
+ /**
+ * Enforces organization data ownership for the given user by transferring vault items.
+ * Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding.
+ *
+ * Rejecting the transfer will result in the user being revoked from the organization.
+ *
+ * @param userId
+ */
+ abstract enforceOrganizationDataOwnership(userId: UserId): Promise;
+
+ /**
+ * Begins transfer of vault items from My Vault to the specified default collection for the given user.
+ */
+ abstract transferPersonalItems(
+ userId: UserId,
+ organizationId: OrganizationId,
+ defaultCollectionId: CollectionId,
+ ): Promise;
+}
diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts
index be0daad3637..391957d26d8 100644
--- a/libs/vault/src/index.ts
+++ b/libs/vault/src/index.ts
@@ -35,5 +35,7 @@ export { DefaultSshImportPromptService } from "./services/default-ssh-import-pro
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service";
+export * from "./abstractions/vault-items-transfer.service";
+export * from "./services/default-vault-items-transfer.service";
export * from "./services/default-change-login-password.service";
export * from "./services/archive-cipher-utilities.service";
diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts
new file mode 100644
index 00000000000..d85fe2ffd43
--- /dev/null
+++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts
@@ -0,0 +1,721 @@
+import { mock, MockProxy } from "jest-mock-extended";
+import { firstValueFrom, of } from "rxjs";
+
+// eslint-disable-next-line no-restricted-imports
+import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
+import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { DialogService, ToastService } from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+import { UserId } from "@bitwarden/user-core";
+
+import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
+
+describe("DefaultVaultItemsTransferService", () => {
+ let service: DefaultVaultItemsTransferService;
+
+ let mockCipherService: MockProxy;
+ let mockPolicyService: MockProxy;
+ let mockOrganizationService: MockProxy;
+ let mockCollectionService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockI18nService: MockProxy;
+ let mockDialogService: MockProxy;
+ let mockToastService: MockProxy;
+ let mockConfigService: MockProxy;
+
+ const userId = "user-id" as UserId;
+ const organizationId = "org-id" as OrganizationId;
+ const collectionId = "collection-id" as CollectionId;
+
+ beforeEach(() => {
+ mockCipherService = mock();
+ mockPolicyService = mock();
+ mockOrganizationService = mock();
+ mockCollectionService = mock();
+ mockLogService = mock();
+ mockI18nService = mock();
+ mockDialogService = mock();
+ mockToastService = mock();
+ mockConfigService = mock();
+
+ mockI18nService.t.mockImplementation((key) => key);
+
+ service = new DefaultVaultItemsTransferService(
+ mockCipherService,
+ mockPolicyService,
+ mockOrganizationService,
+ mockCollectionService,
+ mockLogService,
+ mockI18nService,
+ mockDialogService,
+ mockToastService,
+ mockConfigService,
+ );
+ });
+
+ describe("userMigrationInfo$", () => {
+ // Helper to setup common mock scenario
+ function setupMocksForMigrationScenario(options: {
+ policies?: Policy[];
+ organizations?: Organization[];
+ ciphers?: CipherView[];
+ collections?: CollectionView[];
+ }): void {
+ mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
+ mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
+ mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
+ mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
+ }
+
+ it("calls policiesByType$ with correct PolicyType", async () => {
+ setupMocksForMigrationScenario({ policies: [] });
+
+ await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith(
+ PolicyType.OrganizationDataOwnership,
+ userId,
+ );
+ });
+
+ describe("when no policy exists", () => {
+ beforeEach(() => {
+ setupMocksForMigrationScenario({ policies: [] });
+ });
+
+ it("returns requiresMigration: false", async () => {
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: false,
+ });
+ });
+ });
+
+ describe("when policy exists", () => {
+ const policy = {
+ organizationId: organizationId,
+ revisionDate: new Date("2024-01-01"),
+ } as Policy;
+ const organization = {
+ id: organizationId,
+ name: "Test Org",
+ } as Organization;
+
+ beforeEach(() => {
+ setupMocksForMigrationScenario({
+ policies: [policy],
+ organizations: [organization],
+ });
+ });
+
+ describe("and user has no personal ciphers", () => {
+ beforeEach(() => {
+ mockCipherService.cipherViews$.mockReturnValue(of([]));
+ });
+
+ it("returns requiresMigration: false", async () => {
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: false,
+ enforcingOrganization: organization,
+ defaultCollectionId: undefined,
+ });
+ });
+ });
+
+ describe("and user has personal ciphers", () => {
+ beforeEach(() => {
+ mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView]));
+ });
+
+ it("returns requiresMigration: true", async () => {
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: true,
+ enforcingOrganization: organization,
+ defaultCollectionId: undefined,
+ });
+ });
+
+ it("includes defaultCollectionId when a default collection exists", async () => {
+ mockCollectionService.decryptedCollections$.mockReturnValue(
+ of([
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ]),
+ );
+
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: true,
+ enforcingOrganization: organization,
+ defaultCollectionId: collectionId,
+ });
+ });
+
+ it("returns default collection only for the enforcing organization", async () => {
+ mockCollectionService.decryptedCollections$.mockReturnValue(
+ of([
+ {
+ id: "wrong-collection-id" as CollectionId,
+ organizationId: "wrong-org-id" as OrganizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ]),
+ );
+
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: true,
+ enforcingOrganization: organization,
+ defaultCollectionId: collectionId,
+ });
+ });
+ });
+
+ it("filters out organization ciphers when checking for personal ciphers", async () => {
+ mockCipherService.cipherViews$.mockReturnValue(
+ of([
+ {
+ id: "cipher-1",
+ organizationId: organizationId as string,
+ } as CipherView,
+ ]),
+ );
+
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: false,
+ enforcingOrganization: organization,
+ defaultCollectionId: undefined,
+ });
+ });
+ });
+
+ describe("when multiple policies exist", () => {
+ const olderPolicy = {
+ organizationId: "older-org-id" as OrganizationId,
+ revisionDate: new Date("2024-01-01"),
+ } as Policy;
+ const newerPolicy = {
+ organizationId: organizationId,
+ revisionDate: new Date("2024-06-01"),
+ } as Policy;
+ const olderOrganization = {
+ id: "older-org-id" as OrganizationId,
+ name: "Older Org",
+ } as Organization;
+ const newerOrganization = {
+ id: organizationId,
+ name: "Newer Org",
+ } as Organization;
+
+ beforeEach(() => {
+ setupMocksForMigrationScenario({
+ policies: [newerPolicy, olderPolicy],
+ organizations: [olderOrganization, newerOrganization],
+ ciphers: [{ id: "cipher-1" } as CipherView],
+ });
+ });
+
+ it("uses the oldest policy when selecting enforcing organization", async () => {
+ const result = await firstValueFrom(service.userMigrationInfo$(userId));
+
+ expect(result).toEqual({
+ requiresMigration: true,
+ enforcingOrganization: olderOrganization,
+ defaultCollectionId: undefined,
+ });
+ });
+ });
+ });
+
+ describe("transferPersonalItems", () => {
+ it("does nothing when user has no personal ciphers", async () => {
+ mockCipherService.cipherViews$.mockReturnValue(of([]));
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ expect(mockLogService.info).not.toHaveBeenCalled();
+ });
+
+ it("calls shareManyWithServer with correct parameters", async () => {
+ const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[];
+
+ mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
+ personalCiphers,
+ organizationId,
+ [collectionId],
+ userId,
+ );
+ });
+
+ it("transfers only personal ciphers, not organization ciphers", async () => {
+ const allCiphers = [
+ { id: "cipher-1" },
+ { id: "cipher-2", organizationId: "other-org-id" },
+ { id: "cipher-3" },
+ ] as CipherView[];
+
+ const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]];
+
+ mockCipherService.cipherViews$.mockReturnValue(of(allCiphers));
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
+ expectedPersonalCiphers,
+ organizationId,
+ [collectionId],
+ userId,
+ );
+ });
+
+ it("propagates errors from shareManyWithServer", async () => {
+ const personalCiphers = [{ id: "cipher-1" }] as CipherView[];
+
+ const error = new Error("Transfer failed");
+
+ mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
+ mockCipherService.shareManyWithServer.mockRejectedValue(error);
+
+ await expect(
+ service.transferPersonalItems(userId, organizationId, collectionId),
+ ).rejects.toThrow("Transfer failed");
+ });
+ });
+
+ describe("upgradeOldAttachments", () => {
+ it("upgrades old attachments before transferring", async () => {
+ const cipherWithOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ const upgradedCipher = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: false,
+ attachments: [{ key: "new-key" }],
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$
+ .mockReturnValueOnce(of([cipherWithOldAttachment]))
+ .mockReturnValueOnce(of([upgradedCipher]));
+ mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(
+ cipherWithOldAttachment,
+ userId,
+ );
+ expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
+ [upgradedCipher],
+ organizationId,
+ [collectionId],
+ userId,
+ );
+ });
+
+ it("upgrades multiple ciphers with old attachments", async () => {
+ const cipher1 = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ const cipher2 = {
+ id: "cipher-2",
+ name: "Cipher 2",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView;
+ const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView;
+
+ mockCipherService.cipherViews$
+ .mockReturnValueOnce(of([cipher1, cipher2]))
+ .mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2]));
+ mockCipherService.upgradeOldCipherAttachments
+ .mockResolvedValueOnce(upgradedCipher1)
+ .mockResolvedValueOnce(upgradedCipher2);
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2);
+ expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId);
+ expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId);
+ });
+
+ it("skips attachments that already have keys", async () => {
+ const cipherWithMixedAttachments = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: "existing-key" }, { key: null }],
+ } as unknown as CipherView;
+
+ const upgradedCipher = {
+ ...cipherWithMixedAttachments,
+ hasOldAttachments: false,
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$
+ .mockReturnValueOnce(of([cipherWithMixedAttachments]))
+ .mockReturnValueOnce(of([upgradedCipher]));
+ mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ // Should only be called once for the attachment without a key
+ expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1);
+ });
+
+ it("throws error when upgradeOldCipherAttachments fails", async () => {
+ const cipherWithOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
+ mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed"));
+
+ await expect(
+ service.transferPersonalItems(userId, organizationId, collectionId),
+ ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
+
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("throws error when upgrade returns cipher still having old attachments", async () => {
+ const cipherWithOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ // Upgrade returns but cipher still has old attachments
+ const stillOldCipher = {
+ ...cipherWithOldAttachment,
+ hasOldAttachments: true,
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
+ mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher);
+
+ await expect(
+ service.transferPersonalItems(userId, organizationId, collectionId),
+ ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
+
+ expect(mockLogService.error).toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("throws error when sanity check finds remaining old attachments after upgrade", async () => {
+ const cipherWithOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ const upgradedCipher = {
+ ...cipherWithOldAttachment,
+ hasOldAttachments: false,
+ } as unknown as CipherView;
+
+ // First call returns cipher with old attachment, second call (after upgrade) still returns old attachment
+ mockCipherService.cipherViews$
+ .mockReturnValueOnce(of([cipherWithOldAttachment]))
+ .mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch
+ mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
+
+ await expect(
+ service.transferPersonalItems(userId, organizationId, collectionId),
+ ).rejects.toThrow(
+ "Failed to upgrade all old attachments. 1 ciphers still have old attachments.",
+ );
+
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("logs info when upgrading old attachments", async () => {
+ const cipherWithOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: true,
+ attachments: [{ key: null }],
+ } as unknown as CipherView;
+
+ const upgradedCipher = {
+ ...cipherWithOldAttachment,
+ hasOldAttachments: false,
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$
+ .mockReturnValueOnce(of([cipherWithOldAttachment]))
+ .mockReturnValueOnce(of([upgradedCipher]));
+ mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockLogService.info).toHaveBeenCalledWith(
+ expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"),
+ );
+ expect(mockLogService.info).toHaveBeenCalledWith(
+ expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"),
+ );
+ });
+
+ it("does not upgrade when ciphers have no old attachments", async () => {
+ const cipherWithoutOldAttachment = {
+ id: "cipher-1",
+ name: "Cipher 1",
+ hasOldAttachments: false,
+ } as unknown as CipherView;
+
+ mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment]));
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.transferPersonalItems(userId, organizationId, collectionId);
+
+ expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
+ });
+ });
+
+ describe("enforceOrganizationDataOwnership", () => {
+ const policy = {
+ organizationId: organizationId,
+ revisionDate: new Date("2024-01-01"),
+ } as Policy;
+ const organization = {
+ id: organizationId,
+ name: "Test Org",
+ } as Organization;
+
+ function setupMocksForEnforcementScenario(options: {
+ featureEnabled?: boolean;
+ policies?: Policy[];
+ organizations?: Organization[];
+ ciphers?: CipherView[];
+ collections?: CollectionView[];
+ }): void {
+ mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
+ mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
+ mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
+ mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
+ mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
+ }
+
+ it("does nothing when feature flag is disabled", async () => {
+ setupMocksForEnforcementScenario({
+ featureEnabled: false,
+ policies: [policy],
+ organizations: [organization],
+ ciphers: [{ id: "cipher-1" } as CipherView],
+ collections: [
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ],
+ });
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
+ FeatureFlag.MigrateMyVaultToMyItems,
+ );
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when no migration is required", async () => {
+ setupMocksForEnforcementScenario({ policies: [] });
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when user has no personal ciphers", async () => {
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: [],
+ });
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("logs warning and returns when default collection is missing", async () => {
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: [{ id: "cipher-1" } as CipherView],
+ collections: [],
+ });
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockLogService.warning).toHaveBeenCalledWith(
+ "Default collection is missing for user during organization data ownership enforcement",
+ );
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("shows confirmation dialog when migration is required", async () => {
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: [{ id: "cipher-1" } as CipherView],
+ collections: [
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ],
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
+ title: "Requires migration",
+ content: "Your vault requires migration of personal items to your organization.",
+ type: "warning",
+ });
+ });
+
+ it("does not transfer items when user declines confirmation", async () => {
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: [{ id: "cipher-1" } as CipherView],
+ collections: [
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ],
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
+ });
+
+ it("transfers items and shows success toast when user confirms", async () => {
+ const personalCiphers = [{ id: "cipher-1" } as CipherView];
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: personalCiphers,
+ collections: [
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ],
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(true);
+ mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
+ personalCiphers,
+ organizationId,
+ [collectionId],
+ userId,
+ );
+ expect(mockToastService.showToast).toHaveBeenCalledWith({
+ variant: "success",
+ message: "itemsTransferred",
+ });
+ });
+
+ it("shows error toast when transfer fails", async () => {
+ const personalCiphers = [{ id: "cipher-1" } as CipherView];
+ setupMocksForEnforcementScenario({
+ policies: [policy],
+ organizations: [organization],
+ ciphers: personalCiphers,
+ collections: [
+ {
+ id: collectionId,
+ organizationId: organizationId,
+ isDefaultCollection: true,
+ } as CollectionView,
+ ],
+ });
+ mockDialogService.openSimpleDialog.mockResolvedValue(true);
+ mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
+
+ await service.enforceOrganizationDataOwnership(userId);
+
+ expect(mockLogService.error).toHaveBeenCalledWith(
+ "Error transferring personal items to organization",
+ expect.any(Error),
+ );
+ expect(mockToastService.showToast).toHaveBeenCalledWith({
+ variant: "error",
+ message: "errorOccurred",
+ });
+ });
+ });
+});
diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts
new file mode 100644
index 00000000000..d9c490f870e
--- /dev/null
+++ b/libs/vault/src/services/default-vault-items-transfer.service.ts
@@ -0,0 +1,231 @@
+import { Injectable } from "@angular/core";
+import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
+
+// eslint-disable-next-line no-restricted-imports
+import { CollectionService } from "@bitwarden/admin-console/common";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { getById } from "@bitwarden/common/platform/misc";
+import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
+import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
+import { DialogService, ToastService } from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+import { UserId } from "@bitwarden/user-core";
+
+import {
+ VaultItemsTransferService,
+ UserMigrationInfo,
+} from "../abstractions/vault-items-transfer.service";
+
+@Injectable()
+export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
+ constructor(
+ private cipherService: CipherService,
+ private policyService: PolicyService,
+ private organizationService: OrganizationService,
+ private collectionService: CollectionService,
+ private logService: LogService,
+ private i18nService: I18nService,
+ private dialogService: DialogService,
+ private toastService: ToastService,
+ private configService: ConfigService,
+ ) {}
+
+ private enforcingOrganization$(userId: UserId): Observable {
+ return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
+ map(
+ (policies) =>
+ policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0],
+ ),
+ switchMap((policy) => {
+ if (policy == null) {
+ return of(undefined);
+ }
+ return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId));
+ }),
+ );
+ }
+
+ private personalCiphers$(userId: UserId): Observable {
+ return this.cipherService.cipherViews$(userId).pipe(
+ filterOutNullish(),
+ map((ciphers) => ciphers.filter((c) => c.organizationId == null)),
+ );
+ }
+
+ private defaultUserCollection$(
+ userId: UserId,
+ organizationId: OrganizationId,
+ ): Observable {
+ return this.collectionService.decryptedCollections$(userId).pipe(
+ map((collections) => {
+ return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId)
+ ?.id;
+ }),
+ );
+ }
+
+ userMigrationInfo$(userId: UserId): Observable {
+ return this.enforcingOrganization$(userId).pipe(
+ switchMap((enforcingOrganization) => {
+ if (enforcingOrganization == null) {
+ return of({
+ requiresMigration: false,
+ });
+ }
+ return combineLatest([
+ this.personalCiphers$(userId),
+ this.defaultUserCollection$(userId, enforcingOrganization.id),
+ ]).pipe(
+ map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
+ return {
+ requiresMigration: personalCiphers.length > 0,
+ enforcingOrganization,
+ defaultCollectionId,
+ };
+ }),
+ );
+ }),
+ );
+ }
+
+ async enforceOrganizationDataOwnership(userId: UserId): Promise {
+ const featureEnabled = await this.configService.getFeatureFlag(
+ FeatureFlag.MigrateMyVaultToMyItems,
+ );
+
+ if (!featureEnabled) {
+ return;
+ }
+
+ const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId));
+
+ if (!migrationInfo.requiresMigration) {
+ return;
+ }
+
+ if (migrationInfo.defaultCollectionId == null) {
+ // TODO: Handle creating the default collection if missing (to be handled by AC in future work)
+ this.logService.warning(
+ "Default collection is missing for user during organization data ownership enforcement",
+ );
+ return;
+ }
+
+ // Temporary confirmation dialog. Full implementation in PM-27663
+ const confirmMigration = await this.dialogService.openSimpleDialog({
+ title: "Requires migration",
+ content: "Your vault requires migration of personal items to your organization.",
+ type: "warning",
+ });
+
+ if (!confirmMigration) {
+ // TODO: Show secondary confirmation dialog in PM-27663, for now we just exit
+ // TODO: Revoke user from organization if they decline migration PM-29465
+ return;
+ }
+
+ try {
+ await this.transferPersonalItems(
+ userId,
+ migrationInfo.enforcingOrganization.id,
+ migrationInfo.defaultCollectionId,
+ );
+ this.toastService.showToast({
+ variant: "success",
+ message: this.i18nService.t("itemsTransferred"),
+ });
+ } catch (error) {
+ this.logService.error("Error transferring personal items to organization", error);
+ this.toastService.showToast({
+ variant: "error",
+ message: this.i18nService.t("errorOccurred"),
+ });
+ }
+ }
+
+ async transferPersonalItems(
+ userId: UserId,
+ organizationId: OrganizationId,
+ defaultCollectionId: CollectionId,
+ ): Promise {
+ let personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
+
+ if (personalCiphers.length === 0) {
+ return;
+ }
+
+ const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments);
+
+ if (oldAttachmentCiphers.length > 0) {
+ await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId);
+ personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
+
+ // Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail
+ const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments);
+ if (remainingOldAttachments.length > 0) {
+ throw new Error(
+ `Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`,
+ );
+ }
+ }
+
+ this.logService.info(
+ `Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`,
+ );
+
+ await this.cipherService.shareManyWithServer(
+ personalCiphers,
+ organizationId,
+ [defaultCollectionId],
+ userId,
+ );
+ }
+
+ /**
+ * Upgrades old attachments that don't have attachment keys.
+ * Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key.
+ */
+ private async upgradeOldAttachments(
+ ciphers: CipherView[],
+ userId: UserId,
+ organizationId: OrganizationId,
+ ): Promise {
+ this.logService.info(
+ `Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`,
+ );
+
+ for (const cipher of ciphers) {
+ try {
+ if (!cipher.hasOldAttachments) {
+ continue;
+ }
+
+ const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId);
+
+ if (upgraded.hasOldAttachments) {
+ this.logService.error(
+ `Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`,
+ );
+ throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
+ }
+ } catch (e) {
+ this.logService.error(
+ `Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`,
+ );
+ throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
+ }
+ }
+
+ this.logService.info(
+ `Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`,
+ );
+ }
+}
From 42c09b325c532fb5a6fc55f2633a9c0b5233c57a Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 21:45:44 -0500
Subject: [PATCH 15/60] [deps] Autofill: Update rimraf to v6.1.2 (#17295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package-lock.json | 48 ++++++++++++++++++++++++++++++++++++++++-------
package.json | 2 +-
2 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index cf9b6becf2f..5321edccd18 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -164,7 +164,7 @@
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",
- "rimraf": "6.0.1",
+ "rimraf": "6.1.2",
"sass": "1.94.2",
"sass-loader": "16.0.6",
"storybook": "9.1.16",
@@ -35750,14 +35750,14 @@
"license": "MIT"
},
"node_modules/rimraf": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
- "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz",
+ "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "glob": "^11.0.0",
- "package-json-from-dist": "^1.0.0"
+ "glob": "^13.0.0",
+ "package-json-from-dist": "^1.0.1"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
@@ -35769,6 +35769,40 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/rimraf/node_modules/glob": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
+ "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "path-scurry": "^2.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/minimatch": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
+ "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/brace-expansion": "^5.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
diff --git a/package.json b/package.json
index 58ff0ba8ea5..c7b04c434e7 100644
--- a/package.json
+++ b/package.json
@@ -126,7 +126,7 @@
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",
- "rimraf": "6.0.1",
+ "rimraf": "6.1.2",
"sass": "1.94.2",
"sass-loader": "16.0.6",
"storybook": "9.1.16",
From 3af19ad9340deffdcbf6df943d8b9fddf907c60c Mon Sep 17 00:00:00 2001
From: Bernd Schoolmann
Date: Wed, 10 Dec 2025 04:03:31 +0100
Subject: [PATCH 16/60] [PM-28813] Implement encryption diagnostics & recovery
tool (#17673)
* Implement data recovery tool
* Fix tests
* Move Sdkloadservice call and use bit action
---
.../data-recovery.component.html | 75 ++++
.../data-recovery.component.spec.ts | 348 ++++++++++++++++++
.../data-recovery/data-recovery.component.ts | 208 +++++++++++
.../data-recovery/log-recorder.ts | 19 +
.../data-recovery/steps/cipher-step.ts | 81 ++++
.../data-recovery/steps/folder-step.ts | 97 +++++
.../data-recovery/steps/index.ts | 6 +
.../data-recovery/steps/private-key-step.ts | 93 +++++
.../data-recovery/steps/recovery-step.ts | 43 +++
.../data-recovery/steps/sync-step.ts | 43 +++
.../data-recovery/steps/user-info-step.ts | 49 +++
apps/web/src/app/oss-routing.module.ts | 7 +
apps/web/src/locales/en/messages.json | 48 +++
libs/common/src/enums/feature-flag.enum.ts | 2 +
...ser-asymmetric-key-regeneration.service.ts | 7 +
...symmetric-key-regeneration.service.spec.ts | 49 +++
...ser-asymmetric-key-regeneration.service.ts | 7 +-
17 files changed, 1180 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.html
create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/log-recorder.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/folder-step.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/index.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/sync-step.ts
create mode 100644 apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts
diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.html b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html
new file mode 100644
index 00000000000..f357e516115
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html
@@ -0,0 +1,75 @@
+{{ "dataRecoveryTitle" | i18n }}
+
+
+
+ {{ "dataRecoveryDescription" | i18n }}
+
+
+ @if (!diagnosticsCompleted() && !recoveryCompleted()) {
+
+ }
+
+
+ @for (step of steps(); track $index) {
+ @if (
+ ($index === 0 && hasStarted()) ||
+ ($index > 0 &&
+ (steps()[$index - 1].status === StepStatus.Completed ||
+ steps()[$index - 1].status === StepStatus.Failed))
+ ) {
+
+
+ @if (step.status === StepStatus.Failed) {
+
+ } @else if (step.status === StepStatus.Completed) {
+
+ } @else if (step.status === StepStatus.InProgress) {
+
+ } @else {
+
+ }
+
+
+
+ {{ step.title }}
+
+
+
+ }
+ }
+
+
+ @if (diagnosticsCompleted()) {
+
+ @if (hasIssues() && !recoveryCompleted()) {
+
+ }
+
+
+ }
+
diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts
new file mode 100644
index 00000000000..1976a8dfe27
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts
@@ -0,0 +1,348 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { mock, MockProxy } from "jest-mock-extended";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
+import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
+import { UserId } from "@bitwarden/common/types/guid";
+import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
+import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
+import { DialogService } from "@bitwarden/components";
+import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
+import { LogService } from "@bitwarden/logging";
+
+import { DataRecoveryComponent, StepStatus } from "./data-recovery.component";
+import { RecoveryStep, RecoveryWorkingData } from "./steps";
+
+// Mock SdkLoadService
+jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({
+ SdkLoadService: {
+ Ready: Promise.resolve(),
+ },
+}));
+
+describe("DataRecoveryComponent", () => {
+ let component: DataRecoveryComponent;
+ let fixture: ComponentFixture;
+
+ // Mock Services
+ let mockI18nService: MockProxy;
+ let mockApiService: MockProxy;
+ let mockAccountService: FakeAccountService;
+ let mockKeyService: MockProxy;
+ let mockFolderApiService: MockProxy;
+ let mockCipherEncryptService: MockProxy;
+ let mockDialogService: MockProxy;
+ let mockPrivateKeyRegenerationService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockCryptoFunctionService: MockProxy;
+ let mockFileDownloadService: MockProxy;
+
+ const mockUserId = "user-id" as UserId;
+
+ beforeEach(async () => {
+ mockI18nService = mock();
+ mockApiService = mock();
+ mockAccountService = mockAccountServiceWith(mockUserId);
+ mockKeyService = mock();
+ mockFolderApiService = mock();
+ mockCipherEncryptService = mock();
+ mockDialogService = mock();
+ mockPrivateKeyRegenerationService = mock();
+ mockLogService = mock();
+ mockCryptoFunctionService = mock();
+ mockFileDownloadService = mock();
+
+ mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`);
+
+ await TestBed.configureTestingModule({
+ imports: [DataRecoveryComponent],
+ providers: [
+ { provide: I18nService, useValue: mockI18nService },
+ { provide: ApiService, useValue: mockApiService },
+ { provide: AccountService, useValue: mockAccountService },
+ { provide: KeyService, useValue: mockKeyService },
+ { provide: FolderApiServiceAbstraction, useValue: mockFolderApiService },
+ { provide: CipherEncryptionService, useValue: mockCipherEncryptService },
+ { provide: DialogService, useValue: mockDialogService },
+ {
+ provide: UserAsymmetricKeysRegenerationService,
+ useValue: mockPrivateKeyRegenerationService,
+ },
+ { provide: LogService, useValue: mockLogService },
+ { provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
+ { provide: FileDownloadService, useValue: mockFileDownloadService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DataRecoveryComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ describe("Component Initialization", () => {
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should initialize with default signal values", () => {
+ expect(component.status()).toBe(StepStatus.NotStarted);
+ expect(component.hasStarted()).toBe(false);
+ expect(component.diagnosticsCompleted()).toBe(false);
+ expect(component.recoveryCompleted()).toBe(false);
+ expect(component.hasIssues()).toBe(false);
+ });
+
+ it("should initialize steps in correct order", () => {
+ const steps = component.steps();
+ expect(steps.length).toBe(5);
+ expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n");
+ expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n");
+ expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n");
+ expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n");
+ expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n");
+ });
+ });
+
+ describe("runDiagnostics", () => {
+ let mockSteps: MockProxy[];
+
+ beforeEach(() => {
+ // Create mock steps
+ mockSteps = Array(5)
+ .fill(null)
+ .map(() => {
+ const mockStep = mock();
+ mockStep.title = "mockStep";
+ mockStep.runDiagnostics.mockResolvedValue(true);
+ mockStep.canRecover.mockReturnValue(false);
+ return mockStep;
+ });
+
+ // Replace recovery steps with mocks
+ component["recoverySteps"] = mockSteps;
+ });
+
+ it("should not run if already running", async () => {
+ component["status"].set(StepStatus.InProgress);
+ await component.runDiagnostics();
+
+ expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled();
+ });
+
+ it("should set hasStarted, isRunning and initialize workingData", async () => {
+ await component.runDiagnostics();
+
+ expect(component.hasStarted()).toBe(true);
+ expect(component["workingData"]).toBeDefined();
+ expect(component["workingData"]?.userId).toBeNull();
+ expect(component["workingData"]?.userKey).toBeNull();
+ });
+
+ it("should run diagnostics for all steps", async () => {
+ await component.runDiagnostics();
+
+ mockSteps.forEach((step) => {
+ expect(step.runDiagnostics).toHaveBeenCalledWith(
+ component["workingData"],
+ expect.anything(),
+ );
+ });
+ });
+
+ it("should mark steps as completed when diagnostics succeed", async () => {
+ await component.runDiagnostics();
+
+ const steps = component.steps();
+ steps.forEach((step) => {
+ expect(step.status).toBe(StepStatus.Completed);
+ });
+ });
+
+ it("should mark steps as failed when diagnostics return false", async () => {
+ mockSteps[2].runDiagnostics.mockResolvedValue(false);
+
+ await component.runDiagnostics();
+
+ const steps = component.steps();
+ expect(steps[2].status).toBe(StepStatus.Failed);
+ });
+
+ it("should mark steps as failed when diagnostics throw error", async () => {
+ mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error"));
+
+ await component.runDiagnostics();
+
+ const steps = component.steps();
+ expect(steps[3].status).toBe(StepStatus.Failed);
+ expect(steps[3].message).toBe("Test error");
+ });
+
+ it("should continue diagnostics even if a step fails", async () => {
+ mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed"));
+ mockSteps[3].runDiagnostics.mockResolvedValue(false);
+
+ await component.runDiagnostics();
+
+ // All steps should have been called despite failures
+ mockSteps.forEach((step) => {
+ expect(step.runDiagnostics).toHaveBeenCalled();
+ });
+ });
+
+ it("should set hasIssues to true when a step can recover", async () => {
+ mockSteps[2].runDiagnostics.mockResolvedValue(false);
+ mockSteps[2].canRecover.mockReturnValue(true);
+
+ await component.runDiagnostics();
+
+ expect(component.hasIssues()).toBe(true);
+ });
+
+ it("should set hasIssues to false when no step can recover", async () => {
+ mockSteps.forEach((step) => {
+ step.runDiagnostics.mockResolvedValue(true);
+ step.canRecover.mockReturnValue(false);
+ });
+
+ await component.runDiagnostics();
+
+ expect(component.hasIssues()).toBe(false);
+ });
+
+ it("should set diagnosticsCompleted and status to completed when complete", async () => {
+ await component.runDiagnostics();
+
+ expect(component.diagnosticsCompleted()).toBe(true);
+ expect(component.status()).toBe(StepStatus.Completed);
+ });
+ });
+
+ describe("runRecovery", () => {
+ let mockSteps: MockProxy[];
+ let mockWorkingData: RecoveryWorkingData;
+
+ beforeEach(() => {
+ mockWorkingData = {
+ userId: mockUserId,
+ userKey: null as any,
+ isPrivateKeyCorrupt: false,
+ encryptedPrivateKey: null,
+ ciphers: [],
+ folders: [],
+ };
+
+ mockSteps = Array(5)
+ .fill(null)
+ .map(() => {
+ const mockStep = mock();
+ mockStep.title = "mockStep";
+ mockStep.canRecover.mockReturnValue(false);
+ mockStep.runRecovery.mockResolvedValue();
+ mockStep.runDiagnostics.mockResolvedValue(true);
+ return mockStep;
+ });
+
+ component["recoverySteps"] = mockSteps;
+ component["workingData"] = mockWorkingData;
+ });
+
+ it("should not run if already running", async () => {
+ component["status"].set(StepStatus.InProgress);
+ await component.runRecovery();
+
+ expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
+ });
+
+ it("should not run if workingData is null", async () => {
+ component["workingData"] = null;
+ await component.runRecovery();
+
+ expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
+ });
+
+ it("should only run recovery for steps that can recover", async () => {
+ mockSteps[1].canRecover.mockReturnValue(true);
+ mockSteps[3].canRecover.mockReturnValue(true);
+
+ await component.runRecovery();
+
+ expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
+ expect(mockSteps[1].runRecovery).toHaveBeenCalled();
+ expect(mockSteps[2].runRecovery).not.toHaveBeenCalled();
+ expect(mockSteps[3].runRecovery).toHaveBeenCalled();
+ expect(mockSteps[4].runRecovery).not.toHaveBeenCalled();
+ });
+
+ it("should set recoveryCompleted and status when successful", async () => {
+ mockSteps[1].canRecover.mockReturnValue(true);
+
+ await component.runRecovery();
+
+ expect(component.recoveryCompleted()).toBe(true);
+ expect(component.status()).toBe(StepStatus.Completed);
+ });
+
+ it("should set status to failed if recovery is cancelled", async () => {
+ mockSteps[1].canRecover.mockReturnValue(true);
+ mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
+
+ await component.runRecovery();
+
+ expect(component.status()).toBe(StepStatus.Failed);
+ expect(component.recoveryCompleted()).toBe(false);
+ });
+
+ it("should re-run diagnostics after recovery completes", async () => {
+ mockSteps[1].canRecover.mockReturnValue(true);
+
+ await component.runRecovery();
+
+ // Diagnostics should be called twice: once for initial diagnostic scan
+ mockSteps.forEach((step) => {
+ expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything());
+ });
+ });
+
+ it("should update hasIssues after re-running diagnostics", async () => {
+ // Setup initial state with an issue
+ mockSteps[1].canRecover.mockReturnValue(true);
+ mockSteps[1].runDiagnostics.mockResolvedValue(false);
+
+ // After recovery completes, the issue should be fixed
+ mockSteps[1].runRecovery.mockImplementation(() => {
+ // Simulate recovery fixing the issue
+ mockSteps[1].canRecover.mockReturnValue(false);
+ mockSteps[1].runDiagnostics.mockResolvedValue(true);
+ return Promise.resolve();
+ });
+
+ await component.runRecovery();
+
+ // Verify hasIssues is updated after re-running diagnostics
+ expect(component.hasIssues()).toBe(false);
+ });
+ });
+
+ describe("saveDiagnosticLogs", () => {
+ it("should call fileDownloadService with log content", () => {
+ component.saveDiagnosticLogs();
+
+ expect(mockFileDownloadService.download).toHaveBeenCalledWith({
+ fileName: expect.stringContaining("data-recovery-logs-"),
+ blobData: expect.any(String),
+ blobOptions: { type: "text/plain" },
+ });
+ });
+
+ it("should include timestamp in filename", () => {
+ component.saveDiagnosticLogs();
+
+ const downloadCall = mockFileDownloadService.download.mock.calls[0][0];
+ expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/);
+ });
+ });
+});
diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts
new file mode 100644
index 00000000000..31179dfb062
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts
@@ -0,0 +1,208 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
+
+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 { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
+import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
+import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
+import { ButtonModule, DialogService } from "@bitwarden/components";
+import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
+import { LogService } from "@bitwarden/logging";
+
+import { SharedModule } from "../../shared";
+
+import { LogRecorder } from "./log-recorder";
+import {
+ SyncStep,
+ UserInfoStep,
+ RecoveryStep,
+ PrivateKeyStep,
+ RecoveryWorkingData,
+ FolderStep,
+ CipherStep,
+} from "./steps";
+
+export const StepStatus = Object.freeze({
+ NotStarted: 0,
+ InProgress: 1,
+ Completed: 2,
+ Failed: 3,
+} as const);
+export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
+
+interface StepState {
+ title: string;
+ status: StepStatus;
+ message?: string;
+}
+
+@Component({
+ selector: "app-data-recovery",
+ templateUrl: "data-recovery.component.html",
+ standalone: true,
+ imports: [JslibModule, ButtonModule, CommonModule, SharedModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DataRecoveryComponent {
+ protected readonly StepStatus = StepStatus;
+
+ private i18nService = inject(I18nService);
+ private apiService = inject(ApiService);
+ private accountService = inject(AccountService);
+ private keyService = inject(KeyService);
+ private folderApiService = inject(FolderApiServiceAbstraction);
+ private cipherEncryptService = inject(CipherEncryptionService);
+ private dialogService = inject(DialogService);
+ private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService);
+ private cryptoFunctionService = inject(CryptoFunctionService);
+ private logService = inject(LogService);
+ private fileDownloadService = inject(FileDownloadService);
+
+ private logger: LogRecorder = new LogRecorder(this.logService);
+ private recoverySteps: RecoveryStep[] = [
+ new UserInfoStep(this.accountService, this.keyService),
+ new SyncStep(this.apiService),
+ new PrivateKeyStep(
+ this.privateKeyRegenerationService,
+ this.dialogService,
+ this.cryptoFunctionService,
+ ),
+ new FolderStep(this.folderApiService, this.dialogService),
+ new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
+ ];
+ private workingData: RecoveryWorkingData | null = null;
+
+ readonly status = signal(StepStatus.NotStarted);
+ readonly hasStarted = signal(false);
+ readonly diagnosticsCompleted = signal(false);
+ readonly recoveryCompleted = signal(false);
+ readonly steps = signal(
+ this.recoverySteps.map((step) => ({
+ title: this.i18nService.t(step.title),
+ status: StepStatus.NotStarted,
+ })),
+ );
+ readonly hasIssues = signal(false);
+
+ runDiagnostics = async () => {
+ if (this.status() === StepStatus.InProgress) {
+ return;
+ }
+
+ this.hasStarted.set(true);
+ this.status.set(StepStatus.InProgress);
+ this.diagnosticsCompleted.set(false);
+
+ this.logger.record("Starting diagnostics...");
+ this.workingData = {
+ userId: null,
+ userKey: null,
+ isPrivateKeyCorrupt: false,
+ encryptedPrivateKey: null,
+ ciphers: [],
+ folders: [],
+ };
+
+ await this.runDiagnosticsInternal();
+
+ this.status.set(StepStatus.Completed);
+ this.diagnosticsCompleted.set(true);
+ };
+
+ private async runDiagnosticsInternal() {
+ if (!this.workingData) {
+ this.logger.record("No working data available");
+ return;
+ }
+
+ const currentSteps = this.steps();
+ let hasAnyFailures = false;
+
+ for (let i = 0; i < this.recoverySteps.length; i++) {
+ const step = this.recoverySteps[i];
+ currentSteps[i].status = StepStatus.InProgress;
+ this.steps.set([...currentSteps]);
+
+ this.logger.record(`Running diagnostics for step: ${step.title}`);
+ try {
+ const success = await step.runDiagnostics(this.workingData, this.logger);
+ currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed;
+ if (!success) {
+ hasAnyFailures = true;
+ }
+ this.steps.set([...currentSteps]);
+ this.logger.record(`Diagnostics completed for step: ${step.title}`);
+ } catch (error) {
+ currentSteps[i].status = StepStatus.Failed;
+ currentSteps[i].message = (error as Error).message;
+ this.steps.set([...currentSteps]);
+ this.logger.record(
+ `Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`,
+ );
+ hasAnyFailures = true;
+ }
+ }
+
+ if (hasAnyFailures) {
+ this.logger.record("Diagnostics completed with errors");
+ } else {
+ this.logger.record("Diagnostics completed successfully");
+ }
+
+ // Check if any recovery can be performed
+ const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!));
+ this.hasIssues.set(canRecoverAnyStep);
+ }
+
+ runRecovery = async () => {
+ if (this.status() === StepStatus.InProgress || !this.workingData) {
+ return;
+ }
+
+ this.status.set(StepStatus.InProgress);
+ this.recoveryCompleted.set(false);
+
+ this.logger.record("Starting recovery process...");
+
+ try {
+ for (let i = 0; i < this.recoverySteps.length; i++) {
+ const step = this.recoverySteps[i];
+ if (step.canRecover(this.workingData)) {
+ this.logger.record(`Running recovery for step: ${step.title}`);
+ await step.runRecovery(this.workingData, this.logger);
+ }
+ }
+
+ this.logger.record("Recovery process completed");
+ this.recoveryCompleted.set(true);
+
+ // Re-run diagnostics after recovery
+ this.logger.record("Re-running diagnostics to verify recovery...");
+ await this.runDiagnosticsInternal();
+
+ this.status.set(StepStatus.Completed);
+ } catch (error) {
+ this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
+ this.status.set(StepStatus.Failed);
+ }
+ };
+
+ saveDiagnosticLogs = () => {
+ const logs = this.logger.getLogs();
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const filename = `data-recovery-logs-${timestamp}.txt`;
+
+ const logContent = logs.join("\n");
+ this.fileDownloadService.download({
+ fileName: filename,
+ blobData: logContent,
+ blobOptions: { type: "text/plain" },
+ });
+
+ this.logger.record("Diagnostic logs saved");
+ };
+}
diff --git a/apps/web/src/app/key-management/data-recovery/log-recorder.ts b/apps/web/src/app/key-management/data-recovery/log-recorder.ts
new file mode 100644
index 00000000000..1bca90de48d
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/log-recorder.ts
@@ -0,0 +1,19 @@
+import { LogService } from "@bitwarden/logging";
+
+/**
+ * Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere.
+ */
+export class LogRecorder {
+ private logs: string[] = [];
+
+ constructor(private logService: LogService) {}
+
+ record(message: string) {
+ this.logs.push(message);
+ this.logService.info(`[DataRecovery] ${message}`);
+ }
+
+ getLogs(): string[] {
+ return [...this.logs];
+ }
+}
diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts
new file mode 100644
index 00000000000..34e8cbdc9f3
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts
@@ -0,0 +1,81 @@
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
+import { DialogService } from "@bitwarden/components";
+
+import { LogRecorder } from "../log-recorder";
+
+import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
+
+export class CipherStep implements RecoveryStep {
+ title = "recoveryStepCipherTitle";
+
+ private undecryptableCipherIds: string[] = [];
+
+ constructor(
+ private apiService: ApiService,
+ private cipherService: CipherEncryptionService,
+ private dialogService: DialogService,
+ ) {}
+
+ async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ if (!workingData.userId) {
+ logger.record("Missing user ID");
+ return false;
+ }
+
+ this.undecryptableCipherIds = [];
+ for (const cipher of workingData.ciphers) {
+ try {
+ await this.cipherService.decrypt(cipher, workingData.userId);
+ } catch {
+ logger.record(`Cipher ID ${cipher.id} was undecryptable`);
+ this.undecryptableCipherIds.push(cipher.id);
+ }
+ }
+ logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`);
+
+ return this.undecryptableCipherIds.length == 0;
+ }
+
+ canRecover(workingData: RecoveryWorkingData): boolean {
+ return this.undecryptableCipherIds.length > 0;
+ }
+
+ async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ // Recovery means deleting the broken ciphers.
+ if (this.undecryptableCipherIds.length === 0) {
+ logger.record("No undecryptable ciphers to recover");
+ return;
+ }
+
+ logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`);
+
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "recoveryDeleteCiphersTitle" },
+ content: { key: "recoveryDeleteCiphersDesc" },
+ acceptButtonText: { key: "ok" },
+ cancelButtonText: { key: "cancel" },
+ type: "danger",
+ });
+
+ if (!confirmed) {
+ logger.record("User cancelled cipher deletion");
+ throw new Error("Cipher recovery cancelled by user");
+ }
+
+ logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`);
+
+ for (const cipherId of this.undecryptableCipherIds) {
+ try {
+ await this.apiService.deleteCipher(cipherId);
+ logger.record(`Deleted cipher ${cipherId}`);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`);
+ throw error;
+ }
+ }
+
+ logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`);
+ }
+}
diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts
new file mode 100644
index 00000000000..bc0ae31efba
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts
@@ -0,0 +1,97 @@
+import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
+import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
+import { DialogService } from "@bitwarden/components";
+import { PureCrypto } from "@bitwarden/sdk-internal";
+
+import { LogRecorder } from "../log-recorder";
+
+import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
+
+export class FolderStep implements RecoveryStep {
+ title = "recoveryStepFoldersTitle";
+
+ private undecryptableFolderIds: string[] = [];
+
+ constructor(
+ private folderService: FolderApiServiceAbstraction,
+ private dialogService: DialogService,
+ ) {}
+
+ async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ if (!workingData.userKey) {
+ logger.record("Missing user key");
+ return false;
+ }
+
+ this.undecryptableFolderIds = [];
+ for (const folder of workingData.folders) {
+ if (!folder.name?.encryptedString) {
+ logger.record(`Folder ID ${folder.id} has no name`);
+ this.undecryptableFolderIds.push(folder.id);
+ continue;
+ }
+ try {
+ await SdkLoadService.Ready;
+ PureCrypto.symmetric_decrypt_string(
+ folder.name.encryptedString,
+ workingData.userKey.toEncoded(),
+ );
+ } catch {
+ logger.record(`Folder name for folder ID ${folder.id} was undecryptable`);
+ this.undecryptableFolderIds.push(folder.id);
+ }
+ }
+ logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`);
+
+ return this.undecryptableFolderIds.length == 0;
+ }
+
+ canRecover(workingData: RecoveryWorkingData): boolean {
+ return this.undecryptableFolderIds.length > 0;
+ }
+
+ async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ // Recovery means deleting the broken folders.
+ if (this.undecryptableFolderIds.length === 0) {
+ logger.record("No undecryptable folders to recover");
+ return;
+ }
+
+ if (!workingData.userId) {
+ logger.record("Missing user ID");
+ throw new Error("Missing user ID");
+ }
+
+ logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`);
+
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "recoveryDeleteFoldersTitle" },
+ content: { key: "recoveryDeleteFoldersDesc" },
+ acceptButtonText: { key: "ok" },
+ cancelButtonText: { key: "cancel" },
+ type: "danger",
+ });
+
+ if (!confirmed) {
+ logger.record("User cancelled folder deletion");
+ throw new Error("Folder recovery cancelled by user");
+ }
+
+ logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`);
+
+ for (const folderId of this.undecryptableFolderIds) {
+ try {
+ await this.folderService.delete(folderId, workingData.userId);
+ logger.record(`Deleted folder ${folderId}`);
+ } catch (error) {
+ logger.record(`Failed to delete folder ${folderId}: ${error}`);
+ }
+ }
+
+ logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`);
+ }
+
+ getUndecryptableFolderIds(): string[] {
+ return this.undecryptableFolderIds;
+ }
+}
diff --git a/apps/web/src/app/key-management/data-recovery/steps/index.ts b/apps/web/src/app/key-management/data-recovery/steps/index.ts
new file mode 100644
index 00000000000..caf3cdb34ef
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/index.ts
@@ -0,0 +1,6 @@
+export * from "./sync-step";
+export * from "./user-info-step";
+export * from "./recovery-step";
+export * from "./private-key-step";
+export * from "./folder-step";
+export * from "./cipher-step";
diff --git a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts
new file mode 100644
index 00000000000..82c20c466b8
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts
@@ -0,0 +1,93 @@
+import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
+import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
+import { EncryptionType } from "@bitwarden/common/platform/enums";
+import { DialogService } from "@bitwarden/components";
+import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
+import { PureCrypto } from "@bitwarden/sdk-internal";
+
+import { LogRecorder } from "../log-recorder";
+
+import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
+
+export class PrivateKeyStep implements RecoveryStep {
+ title = "recoveryStepPrivateKeyTitle";
+
+ constructor(
+ private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
+ private dialogService: DialogService,
+ private cryptoFunctionService: CryptoFunctionService,
+ ) {}
+
+ async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ if (!workingData.userId || !workingData.userKey) {
+ logger.record("Missing user ID or user key");
+ return false;
+ }
+
+ // Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation.
+ const encryptedPrivateKey = workingData.encryptedPrivateKey;
+ if (!encryptedPrivateKey) {
+ logger.record("No encrypted private key found");
+ return false;
+ }
+ logger.record("Private key length: " + encryptedPrivateKey.length);
+ let privateKey: Uint8Array;
+ try {
+ await SdkLoadService.Ready;
+ privateKey = PureCrypto.unwrap_decapsulation_key(
+ encryptedPrivateKey,
+ workingData.userKey.toEncoded(),
+ );
+ } catch {
+ logger.record("Private key was un-decryptable");
+ workingData.isPrivateKeyCorrupt = true;
+ return false;
+ }
+
+ // Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding.
+ try {
+ const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
+ logger.record("Public key length: " + publicKey.length);
+ } catch {
+ logger.record("Public key could not be derived; private key is corrupt");
+ workingData.isPrivateKeyCorrupt = true;
+ return false;
+ }
+
+ return true;
+ }
+
+ canRecover(workingData: RecoveryWorkingData): boolean {
+ // Only support recovery on V1 users.
+ return (
+ workingData.isPrivateKeyCorrupt &&
+ workingData.userKey !== null &&
+ workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64
+ );
+ }
+
+ async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ // The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization.
+ // This is because this will break emergency access enrollments / organization memberships / provider memberships.
+ logger.record("Showing confirmation dialog for private key replacement");
+
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "recoveryReplacePrivateKeyTitle" },
+ content: { key: "recoveryReplacePrivateKeyDesc" },
+ acceptButtonText: { key: "ok" },
+ cancelButtonText: { key: "cancel" },
+ type: "danger",
+ });
+
+ if (!confirmed) {
+ logger.record("User cancelled private key replacement");
+ throw new Error("Private key recovery cancelled by user");
+ }
+
+ logger.record("Replacing private key");
+ await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair(
+ workingData.userId!,
+ );
+ logger.record("Private key replaced successfully");
+ }
+}
diff --git a/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts
new file mode 100644
index 00000000000..265d7c68284
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts
@@ -0,0 +1,43 @@
+import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
+import { UserKey } from "@bitwarden/common/types/key";
+import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
+import { Folder } from "@bitwarden/common/vault/models/domain/folder";
+import { UserId } from "@bitwarden/user-core";
+
+import { LogRecorder } from "../log-recorder";
+
+/**
+ * A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers.
+ */
+export abstract class RecoveryStep {
+ /** Title of the recovery step, as an i18n key. */
+ abstract title: string;
+
+ /**
+ * Runs diagnostics on the provided working data.
+ * Returns true if no issues were found, false otherwise.
+ */
+ abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise;
+
+ /**
+ * Returns whether recovery can be performed
+ */
+ abstract canRecover(workingData: RecoveryWorkingData): boolean;
+
+ /**
+ * Performs recovery on the provided working data.
+ */
+ abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise;
+}
+
+/**
+ * Data used during the recovery process, passed between steps.
+ */
+export type RecoveryWorkingData = {
+ userId: UserId | null;
+ userKey: UserKey | null;
+ encryptedPrivateKey: WrappedPrivateKey | null;
+ isPrivateKeyCorrupt: boolean;
+ ciphers: Cipher[];
+ folders: Folder[];
+};
diff --git a/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts
new file mode 100644
index 00000000000..f0adb1e0b46
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts
@@ -0,0 +1,43 @@
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
+import { FolderData } from "@bitwarden/common/vault/models/data/folder.data";
+import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
+import { Folder } from "@bitwarden/common/vault/models/domain/folder";
+
+import { LogRecorder } from "../log-recorder";
+
+import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
+
+export class SyncStep implements RecoveryStep {
+ title = "recoveryStepSyncTitle";
+
+ constructor(private apiService: ApiService) {}
+
+ async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ // The intent of this step is to fetch the latest data from the server. Diagnostics does not
+ // ever run on local data but only remote data that is recent.
+ const response = await this.apiService.getSync();
+
+ workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c)));
+ logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`);
+
+ workingData.folders = response.folders.map((f) => new Folder(new FolderData(f)));
+ logger.record(`Fetched ${workingData.folders.length} folders from server`);
+
+ workingData.encryptedPrivateKey =
+ response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null;
+ logger.record(
+ `Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`,
+ );
+
+ return true;
+ }
+
+ canRecover(workingData: RecoveryWorkingData): boolean {
+ return false;
+ }
+
+ runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ return Promise.resolve();
+ }
+}
diff --git a/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts
new file mode 100644
index 00000000000..9565b1da73b
--- /dev/null
+++ b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts
@@ -0,0 +1,49 @@
+import { firstValueFrom } from "rxjs";
+
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { EncryptionType } from "@bitwarden/common/platform/enums";
+import { KeyService } from "@bitwarden/key-management";
+
+import { LogRecorder } from "../log-recorder";
+
+import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
+
+export class UserInfoStep implements RecoveryStep {
+ title = "recoveryStepUserInfoTitle";
+
+ constructor(
+ private accountService: AccountService,
+ private keyService: KeyService,
+ ) {}
+
+ async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
+ if (!activeAccount) {
+ logger.record("No active account found");
+ return false;
+ }
+ const userId = activeAccount.id;
+ workingData.userId = userId;
+ logger.record(`User ID: ${userId}`);
+
+ const userKey = await firstValueFrom(this.keyService.userKey$(userId));
+ if (!userKey) {
+ logger.record("No user key found");
+ return false;
+ }
+ workingData.userKey = userKey;
+ logger.record(
+ `User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`,
+ );
+
+ return true;
+ }
+
+ canRecover(workingData: RecoveryWorkingData): boolean {
+ return false;
+ }
+
+ runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise {
+ return Promise.resolve();
+ }
+}
diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts
index b40b9143991..ac9bdc4b946 100644
--- a/apps/web/src/app/oss-routing.module.ts
+++ b/apps/web/src/app/oss-routing.module.ts
@@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
import { RouteDataProperties } from "./core";
import { ReportsModule } from "./dirt/reports";
+import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component";
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
@@ -696,6 +697,12 @@ const routes: Routes = [
path: "security",
loadChildren: () => SecurityRoutingModule,
},
+ {
+ path: "data-recovery",
+ component: DataRecoveryComponent,
+ canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)],
+ data: { titleId: "dataRecovery" } satisfies RouteDataProperties,
+ },
{
path: "domain-rules",
component: DomainRulesComponent,
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index a755e4de556..c827f09d173 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -12253,6 +12253,54 @@
"userVerificationFailed": {
"message": "User verification failed."
},
+ "recoveryDeleteCiphersTitle": {
+ "message": "Delete unrecoverable vault items"
+ },
+ "recoveryDeleteCiphersDesc": {
+ "message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?"
+ },
+ "recoveryDeleteFoldersTitle": {
+ "message": "Delete unrecoverable folders"
+ },
+ "recoveryDeleteFoldersDesc": {
+ "message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?"
+ },
+ "recoveryReplacePrivateKeyTitle": {
+ "message": "Replace encryption key"
+ },
+ "recoveryReplacePrivateKeyDesc": {
+ "message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again."
+ },
+ "recoveryStepSyncTitle": {
+ "message": "Synchronizing data"
+ },
+ "recoveryStepPrivateKeyTitle": {
+ "message": "Verifying encryption key integrity"
+ },
+ "recoveryStepUserInfoTitle": {
+ "message": "Verifying user information"
+ },
+ "recoveryStepCipherTitle": {
+ "message": "Verifying vault item integrity"
+ },
+ "recoveryStepFoldersTitle": {
+ "message": "Verifying folder integrity"
+ },
+ "dataRecoveryTitle": {
+ "message": "Data Recovery and Diagnostics"
+ },
+ "dataRecoveryDescription": {
+ "message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues."
+ },
+ "runDiagnostics": {
+ "message": "Run Diagnostics"
+ },
+ "repairIssues": {
+ "message": "Repair Issues"
+ },
+ "saveDiagnosticLogs": {
+ "message": "Save Diagnostic Logs"
+ },
"sessionTimeoutSettingsManagedByOrganization": {
"message": "This setting is managed by your organization."
},
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 1727d3da712..fb8edd8aa7d 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -43,6 +43,7 @@ export enum FeatureFlag {
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
+ DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
/* Tools */
@@ -149,6 +150,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
+ [FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */
diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts
index 4703d836db7..58620f49ed1 100644
--- a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts
+++ b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts
@@ -7,4 +7,11 @@ export abstract class UserAsymmetricKeysRegenerationService {
* @param userId The user id.
*/
abstract regenerateIfNeeded(userId: UserId): Promise;
+
+ /**
+ * Performs the regeneration of the user's public/private key pair without checking any preconditions.
+ * This should only be used for V1 encryption accounts
+ * @param userId The user id.
+ */
+ abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise;
}
diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts
index e57ab74de6b..92e5240a187 100644
--- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts
+++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts
@@ -370,3 +370,52 @@ describe("regenerateIfNeeded", () => {
);
});
});
+
+describe("regenerateUserPublicKeyEncryptionKeyPair", () => {
+ let sut: DefaultUserAsymmetricKeysRegenerationService;
+ const userId = "userId" as UserId;
+
+ let keyService: MockProxy;
+ let cipherService: MockProxy;
+ let userAsymmetricKeysRegenerationApiService: MockProxy;
+ let logService: MockProxy;
+ let sdkService: MockSdkService;
+ let apiService: MockProxy;
+ let configService: MockProxy;
+
+ beforeEach(() => {
+ keyService = mock();
+ cipherService = mock();
+ userAsymmetricKeysRegenerationApiService = mock();
+ logService = mock();
+ sdkService = new MockSdkService();
+ apiService = mock();
+ configService = mock();
+
+ sut = new DefaultUserAsymmetricKeysRegenerationService(
+ keyService,
+ cipherService,
+ userAsymmetricKeysRegenerationApiService,
+ logService,
+ sdkService,
+ apiService,
+ configService,
+ );
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("should throw error when user key is not V1 encryption type", async () => {
+ const mockUserKey = {
+ keyB64: "mockKeyB64",
+ inner: () => ({ type: 7 }),
+ } as unknown as UserKey;
+ keyService.userKey$.mockReturnValue(of(mockUserKey));
+
+ await expect(sut.regenerateUserPublicKeyEncryptionKeyPair(userId)).rejects.toThrow(
+ "User key is not V1 encryption type",
+ );
+ });
+});
diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts
index 335f45b0ce2..48fe3a1686f 100644
--- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts
+++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts
@@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
if (privateKeyRegenerationFlag) {
const shouldRegenerate = await this.shouldRegenerate(userId);
if (shouldRegenerate) {
- await this.regenerateUserAsymmetricKeys(userId);
+ await this.regenerateUserPublicKeyEncryptionKeyPair(userId);
}
}
} catch (error) {
@@ -125,11 +125,14 @@ export class DefaultUserAsymmetricKeysRegenerationService
return false;
}
- private async regenerateUserAsymmetricKeys(userId: UserId): Promise {
+ async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("User key not found");
}
+ if (userKey.inner().type !== EncryptionType.AesCbc256_HmacSha256_B64) {
+ throw new Error("User key is not V1 encryption type");
+ }
const makeKeyPairResponse = await firstValueFrom(
this.sdkService.client$.pipe(
map((sdk) => {
From 3e9db6b472a04d94da214336add1fa4a3814a48e Mon Sep 17 00:00:00 2001
From: bmbitwarden
Date: Tue, 9 Dec 2025 23:49:58 -0500
Subject: [PATCH 17/60] PM-27628 Rename Remove individual vault export policy
(#17335)
* PM-27628 conditions for send and export links in left navbar
* PM-27628 resolved claude comment for pr
* PM-27628 resolved claude comment for pr
* PM-27628 reverted earlier display conditionals and changed label
* PM-27628 changed out keys as well
* PM-27628 revert description key change
---
apps/web/src/locales/en/messages.json | 4 ++--
.../disable-personal-vault-export.component.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index c827f09d173..85159c0230c 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -6815,8 +6815,8 @@
"vaultTimeoutRangeError": {
"message": "Vault timeout is not within allowed range."
},
- "disablePersonalVaultExport": {
- "message": "Remove individual vault export"
+ "disableExport": {
+ "message": "Remove export"
},
"disablePersonalVaultExportDescription": {
"message": "Do not allow members to export data from their individual vault."
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts
index 0f0fc5f358d..5a9b36912c9 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts
@@ -8,7 +8,7 @@ import {
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class DisablePersonalVaultExportPolicy extends BasePolicyEditDefinition {
- name = "disablePersonalVaultExport";
+ name = "disableExport";
description = "disablePersonalVaultExportDescription";
type = PolicyType.DisablePersonalVaultExport;
component = DisablePersonalVaultExportPolicyComponent;
From 663ef60ae5113115c092578193cf52c504f42572 Mon Sep 17 00:00:00 2001
From: Isaac Ivins
Date: Wed, 10 Dec 2025 04:02:30 -0500
Subject: [PATCH 18/60] Feature/pm 27795 migrate send filters desktop migration
(#17802)
Created a new navigation component that renders Send type filters as sidebar navigation items.
---
.../app/layout/desktop-layout.component.html | 2 +-
.../layout/desktop-layout.component.spec.ts | 25 ++-
.../app/layout/desktop-layout.component.ts | 11 +-
.../send-v2/send-filters-nav.component.html | 25 +++
.../send-filters-nav.component.spec.ts | 204 ++++++++++++++++++
.../send-v2/send-filters-nav.component.ts | 54 +++++
.../tools/send-v2/send-v2.component.spec.ts | 34 ++-
.../app/tools/send-v2/send-v2.component.ts | 44 +++-
8 files changed, 382 insertions(+), 17 deletions(-)
create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html
create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts
create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html
index 7f8bd265102..1717b29acd1 100644
--- a/apps/desktop/src/app/layout/desktop-layout.component.html
+++ b/apps/desktop/src/app/layout/desktop-layout.component.html
@@ -3,7 +3,7 @@
-
+
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts
index cc2f7e58dfb..74cddd02495 100644
--- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts
+++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts
@@ -1,3 +1,4 @@
+import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterModule } from "@angular/router";
import { mock } from "jest-mock-extended";
@@ -5,8 +6,18 @@ import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { NavigationModule } from "@bitwarden/components";
+import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
+
import { DesktopLayoutComponent } from "./desktop-layout.component";
+// Mock the child component to isolate DesktopLayoutComponent testing
+@Component({
+ selector: "app-send-filters-nav",
+ template: "",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+class MockSendFiltersNavComponent {}
+
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
@@ -34,7 +45,12 @@ describe("DesktopLayoutComponent", () => {
useValue: mock(),
},
],
- }).compileComponents();
+ })
+ .overrideComponent(DesktopLayoutComponent, {
+ remove: { imports: [SendFiltersNavComponent] },
+ add: { imports: [MockSendFiltersNavComponent] },
+ })
+ .compileComponents();
fixture = TestBed.createComponent(DesktopLayoutComponent);
component = fixture.componentInstance;
@@ -58,4 +74,11 @@ describe("DesktopLayoutComponent", () => {
expect(ngContent).toBeTruthy();
});
+
+ it("renders send filters navigation component", () => {
+ const compiled = fixture.nativeElement;
+ const sendFiltersNav = compiled.querySelector("app-send-filters-nav");
+
+ expect(sendFiltersNav).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts
index 006055f475f..0ee7065fba8 100644
--- a/apps/desktop/src/app/layout/desktop-layout.component.ts
+++ b/apps/desktop/src/app/layout/desktop-layout.component.ts
@@ -5,13 +5,22 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
+import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component";
+
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],
+ imports: [
+ RouterModule,
+ I18nPipe,
+ LayoutComponent,
+ NavigationModule,
+ DesktopSideNavComponent,
+ SendFiltersNavComponent,
+ ],
templateUrl: "./desktop-layout.component.html",
})
export class DesktopLayoutComponent {
diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html
new file mode 100644
index 00000000000..64c52b50a49
--- /dev/null
+++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts
new file mode 100644
index 00000000000..95ba5c53e36
--- /dev/null
+++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts
@@ -0,0 +1,204 @@
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { Router, provideRouter } from "@angular/router";
+import { RouterTestingHarness } from "@angular/router/testing";
+import { BehaviorSubject } from "rxjs";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { NavigationModule } from "@bitwarden/components";
+import { SendListFiltersService } from "@bitwarden/send-ui";
+
+import { SendFiltersNavComponent } from "./send-filters-nav.component";
+
+@Component({ template: "", changeDetection: ChangeDetectionStrategy.OnPush })
+class DummyComponent {}
+
+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("SendFiltersNavComponent", () => {
+ let component: SendFiltersNavComponent;
+ let fixture: ComponentFixture;
+ let harness: RouterTestingHarness;
+ let filterFormValueSubject: BehaviorSubject<{ sendType: SendType | null }>;
+ let mockSendListFiltersService: Partial;
+
+ beforeEach(async () => {
+ filterFormValueSubject = new BehaviorSubject<{ sendType: SendType | null }>({
+ sendType: null,
+ });
+
+ mockSendListFiltersService = {
+ filterForm: {
+ value: { sendType: null },
+ valueChanges: filterFormValueSubject.asObservable(),
+ patchValue: jest.fn((value) => {
+ mockSendListFiltersService.filterForm.value = {
+ ...mockSendListFiltersService.filterForm.value,
+ ...value,
+ };
+ filterFormValueSubject.next(mockSendListFiltersService.filterForm.value);
+ }),
+ } as any,
+ filters$: filterFormValueSubject.asObservable(),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [SendFiltersNavComponent, NavigationModule],
+ providers: [
+ provideRouter([
+ { path: "vault", component: DummyComponent },
+ { path: "new-sends", component: DummyComponent },
+ ]),
+ {
+ provide: SendListFiltersService,
+ useValue: mockSendListFiltersService,
+ },
+ {
+ provide: I18nService,
+ useValue: {
+ t: jest.fn((key) => key),
+ },
+ },
+ ],
+ }).compileComponents();
+
+ // Create harness and navigate to initial route
+ harness = await RouterTestingHarness.create("/vault");
+
+ // Create the component fixture separately (not a routed component)
+ fixture = TestBed.createComponent(SendFiltersNavComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("renders bit-nav-group with Send icon and text", () => {
+ const compiled = fixture.nativeElement;
+ const navGroup = compiled.querySelector("bit-nav-group");
+
+ expect(navGroup).toBeTruthy();
+ expect(navGroup.getAttribute("icon")).toBe("bwi-send");
+ });
+
+ it("component exposes SendType enum for template", () => {
+ expect(component["SendType"]).toBe(SendType);
+ });
+
+ describe("isSendRouteActive", () => {
+ it("returns true when on /new-sends route", async () => {
+ await harness.navigateByUrl("/new-sends");
+ fixture.detectChanges();
+
+ expect(component["isSendRouteActive"]()).toBe(true);
+ });
+
+ it("returns false when not on /new-sends route", () => {
+ expect(component["isSendRouteActive"]()).toBe(false);
+ });
+ });
+
+ describe("activeSendType", () => {
+ it("returns the active send type when on send route and filter type is set", async () => {
+ await harness.navigateByUrl("/new-sends");
+ mockSendListFiltersService.filterForm.value = { sendType: SendType.Text };
+ filterFormValueSubject.next({ sendType: SendType.Text });
+ fixture.detectChanges();
+
+ expect(component["activeSendType"]()).toBe(SendType.Text);
+ });
+
+ it("returns undefined when not on send route", () => {
+ mockSendListFiltersService.filterForm.value = { sendType: SendType.Text };
+ filterFormValueSubject.next({ sendType: SendType.Text });
+ fixture.detectChanges();
+
+ expect(component["activeSendType"]()).toBeUndefined();
+ });
+
+ it("returns null when on send route but no type is selected", async () => {
+ await harness.navigateByUrl("/new-sends");
+ mockSendListFiltersService.filterForm.value = { sendType: null };
+ filterFormValueSubject.next({ sendType: null });
+ fixture.detectChanges();
+
+ expect(component["activeSendType"]()).toBeNull();
+ });
+ });
+
+ describe("selectTypeAndNavigate", () => {
+ it("clears the sendType filter when called with no parameter", async () => {
+ await component["selectTypeAndNavigate"]();
+
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: null,
+ });
+ });
+
+ it("updates filter form with Text type", async () => {
+ await component["selectTypeAndNavigate"](SendType.Text);
+
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: SendType.Text,
+ });
+ });
+
+ it("updates filter form with File type", async () => {
+ await component["selectTypeAndNavigate"](SendType.File);
+
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: SendType.File,
+ });
+ });
+
+ it("navigates to /new-sends when not on send route", async () => {
+ expect(harness.routeNativeElement?.textContent).toBeDefined();
+
+ await component["selectTypeAndNavigate"](SendType.Text);
+
+ const currentUrl = TestBed.inject(Router).url;
+ expect(currentUrl).toBe("/new-sends");
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: SendType.Text,
+ });
+ });
+
+ it("does not navigate when already on send route (component is reactive)", async () => {
+ await harness.navigateByUrl("/new-sends");
+ const router = TestBed.inject(Router);
+ const navigateSpy = jest.spyOn(router, "navigate");
+
+ await component["selectTypeAndNavigate"](SendType.Text);
+
+ expect(navigateSpy).not.toHaveBeenCalled();
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: SendType.Text,
+ });
+ });
+
+ it("navigates when clearing filter from different route", async () => {
+ await component["selectTypeAndNavigate"](); // No parameter = clear filter
+
+ const currentUrl = TestBed.inject(Router).url;
+ expect(currentUrl).toBe("/new-sends");
+ expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({
+ sendType: null,
+ });
+ });
+ });
+});
diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts
new file mode 100644
index 00000000000..28004f475e5
--- /dev/null
+++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts
@@ -0,0 +1,54 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core";
+import { toSignal } from "@angular/core/rxjs-interop";
+import { NavigationEnd, Router } from "@angular/router";
+import { filter, map, startWith } from "rxjs";
+
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { NavigationModule } from "@bitwarden/components";
+import { SendListFiltersService } from "@bitwarden/send-ui";
+import { I18nPipe } from "@bitwarden/ui-common";
+
+/**
+ * Navigation component that renders Send filter options in the sidebar.
+ * Fully reactive using signals - no manual subscriptions or method-based computed values.
+ * - Parent "Send" nav-group clears filter (shows all sends)
+ * - Child "Text"/"File" items set filter to specific type
+ * - Active states computed reactively from filter signal + route signal
+ */
+@Component({
+ selector: "app-send-filters-nav",
+ templateUrl: "./send-filters-nav.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [CommonModule, NavigationModule, I18nPipe],
+})
+export class SendFiltersNavComponent {
+ protected readonly SendType = SendType;
+ private readonly filtersService = inject(SendListFiltersService);
+ private readonly router = inject(Router);
+ private readonly currentFilter = toSignal(this.filtersService.filters$);
+
+ // Track whether current route is the send route
+ private readonly isSendRouteActive = toSignal(
+ this.router.events.pipe(
+ filter((event) => event instanceof NavigationEnd),
+ map((event) => (event as NavigationEnd).urlAfterRedirects.includes("/new-sends")),
+ startWith(this.router.url.includes("/new-sends")),
+ ),
+ { initialValue: this.router.url.includes("/new-sends") },
+ );
+
+ // Computed: Active send type (null when on send route with no filter, undefined when not on send route)
+ protected readonly activeSendType = computed(() => {
+ return this.isSendRouteActive() ? this.currentFilter()?.sendType : undefined;
+ });
+
+ // Update send filter and navigate to /new-sends (only if not already there - send-v2 component reacts to filter changes)
+ protected async selectTypeAndNavigate(type?: SendType): Promise {
+ this.filtersService.filterForm.patchValue({ sendType: type !== undefined ? type : null });
+
+ if (!this.router.url.includes("/new-sends")) {
+ await this.router.navigate(["/new-sends"]);
+ }
+ }
+}
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
index 5798df0989d..8657f3e375e 100644
--- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
+++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts
@@ -1,4 +1,8 @@
+// FIXME: Update this file to be type safe and remove this and next line
+// @ts-strict-ignore
+import { ChangeDetectorRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormBuilder } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
@@ -15,6 +19,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
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 { SendListFiltersService } from "@bitwarden/send-ui";
import * as utils from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
@@ -35,6 +40,8 @@ describe("SendV2Component", () => {
let broadcasterService: MockProxy;
let accountService: MockProxy;
let policyService: MockProxy;
+ let sendListFiltersService: SendListFiltersService;
+ let changeDetectorRef: MockProxy;
beforeEach(async () => {
sendService = mock();
@@ -42,6 +49,13 @@ describe("SendV2Component", () => {
broadcasterService = mock();
accountService = mock();
policyService = mock();
+ changeDetectorRef = mock();
+
+ // Create real SendListFiltersService with mocked dependencies
+ const formBuilder = new FormBuilder();
+ const i18nService = mock();
+ i18nService.t.mockImplementation((key: string) => key);
+ sendListFiltersService = new SendListFiltersService(i18nService, formBuilder);
// Mock sendViews$ observable
sendService.sendViews$ = of([]);
@@ -51,6 +65,10 @@ describe("SendV2Component", () => {
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
+ // Mock SearchService methods needed by base component
+ const mockSearchService = mock();
+ mockSearchService.isSearchable.mockResolvedValue(false);
+
await TestBed.configureTestingModule({
imports: [SendV2Component],
providers: [
@@ -59,7 +77,7 @@ describe("SendV2Component", () => {
{ provide: PlatformUtilsService, useValue: mock() },
{ provide: EnvironmentService, useValue: mock() },
{ provide: BroadcasterService, useValue: broadcasterService },
- { provide: SearchService, useValue: mock() },
+ { provide: SearchService, useValue: mockSearchService },
{ provide: PolicyService, useValue: policyService },
{ provide: SearchBarService, useValue: searchBarService },
{ provide: LogService, useValue: mock() },
@@ -67,6 +85,8 @@ describe("SendV2Component", () => {
{ provide: DialogService, useValue: mock() },
{ provide: ToastService, useValue: mock() },
{ provide: AccountService, useValue: accountService },
+ { provide: SendListFiltersService, useValue: sendListFiltersService },
+ { provide: ChangeDetectorRef, useValue: changeDetectorRef },
],
}).compileComponents();
@@ -331,7 +351,6 @@ describe("SendV2Component", () => {
describe("load", () => {
it("sets loading states correctly", async () => {
jest.spyOn(component, "search").mockResolvedValue();
- jest.spyOn(component, "selectAll");
expect(component.loaded).toBeFalsy();
@@ -341,14 +360,17 @@ describe("SendV2Component", () => {
expect(component.loaded).toBe(true);
});
- it("calls selectAll when onSuccessfulLoad is not set", async () => {
+ it("sets up sendViews$ subscription", async () => {
+ const mockSends = [new SendView(), new SendView()];
+ sendService.sendViews$ = of(mockSends);
jest.spyOn(component, "search").mockResolvedValue();
- jest.spyOn(component, "selectAll");
- component.onSuccessfulLoad = null;
await component.load();
- expect(component.selectAll).toHaveBeenCalled();
+ // Give observable time to emit
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(component.sends).toEqual(mockSends);
});
it("calls onSuccessfulLoad when it is set", async () => {
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts
index 4afe02d9f98..eb0856b76af 100644
--- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts
+++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts
@@ -2,8 +2,9 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
-import { mergeMap } from "rxjs";
+import { mergeMap, Subscription } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
@@ -14,11 +15,13 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
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 { SendListFiltersService } from "@bitwarden/send-ui";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { SearchBarService } from "../../layout/search/search-bar.service";
@@ -55,6 +58,9 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
action: Action = Action.None;
+ // Subscription for sendViews$ cleanup
+ private sendViewsSubscription: Subscription;
+
constructor(
sendService: SendService,
i18nService: I18nService,
@@ -71,6 +77,7 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
toastService: ToastService,
accountService: AccountService,
private cdr: ChangeDetectorRef,
+ private sendListFiltersService: SendListFiltersService,
) {
super(
sendService,
@@ -88,12 +95,17 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
);
// 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.searchBarService.searchText$.pipe(takeUntilDestroyed()).subscribe((searchText) => {
this.searchText = searchText;
this.searchTextChanged();
- setTimeout(() => this.cdr.detectChanges(), 250);
});
+
+ // Listen to filter changes from sidebar navigation
+ this.sendListFiltersService.filterForm.valueChanges
+ .pipe(takeUntilDestroyed())
+ .subscribe((filters) => {
+ this.applySendTypeFilter(filters);
+ });
}
// Initialize the component: enable search bar, subscribe to sync events, and load Send items
@@ -103,6 +115,10 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
await super.ngOnInit();
+ // Read current filter synchronously to avoid race condition on navigation
+ const currentFilter = this.sendListFiltersService.filterForm.value;
+ this.applySendTypeFilter(currentFilter);
+
// 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.
@@ -118,8 +134,18 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
await this.load();
}
+ // Apply send type filter to display: centralized logic for initial load and filter changes
+ private applySendTypeFilter(filters: Partial<{ sendType: SendType | null }>): void {
+ if (filters.sendType === null || filters.sendType === undefined) {
+ this.selectAll();
+ } else {
+ this.selectType(filters.sendType);
+ }
+ }
+
// Clean up subscriptions and disable search bar when component is destroyed
ngOnDestroy() {
+ this.sendViewsSubscription?.unsubscribe();
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.searchBarService.setEnabled(false);
}
@@ -130,7 +156,12 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
// 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$
+
+ // Recreate subscription on each load (required for sync refresh)
+ // Manual cleanup in ngOnDestroy is intentional - load() is called multiple times
+ this.sendViewsSubscription?.unsubscribe();
+
+ this.sendViewsSubscription = this.sendService.sendViews$
.pipe(
mergeMap(async (sends) => {
this.sends = sends;
@@ -143,9 +174,6 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest
.subscribe();
if (this.onSuccessfulLoad != null) {
await this.onSuccessfulLoad();
- } else {
- // Default action
- this.selectAll();
}
this.loading = false;
this.loaded = true;
From 6e383ecbc6cfe68f73959d38c49227425b064226 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 10 Dec 2025 13:23:04 +0100
Subject: [PATCH 19/60] [deps]: Update peter-evans/repository-dispatch action
to v4.0.1 (#17891)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/test-browser-interactions.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml
index dfc0f28b9c6..c8f4c959c52 100644
--- a/.github/workflows/test-browser-interactions.yml
+++ b/.github/workflows/test-browser-interactions.yml
@@ -75,7 +75,7 @@ jobs:
- name: Trigger test-all workflow in browser-interactions-testing
if: steps.changed-files.outputs.monitored == 'true'
- uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
+ uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ steps.app-token.outputs.token }}
repository: "bitwarden/browser-interactions-testing"
From 0301e9d1d7ad1c42127451372c66a99ff7bdff49 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 10 Dec 2025 07:57:58 -0600
Subject: [PATCH 20/60] [deps]: Update Rust crate tokio-util to v0.7.17
(#17575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 4 ++--
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index cf946f7f204..1b98e677ac7 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -3329,9 +3329,9 @@ dependencies = [
[[package]]
name = "tokio-util"
-version = "0.7.13"
+version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078"
+checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index 59df7ba57fb..b492fc62e2a 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -63,7 +63,7 @@ ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.37.2"
thiserror = "=2.0.17"
tokio = "=1.45.0"
-tokio-util = "=0.7.13"
+tokio-util = "=0.7.17"
tracing = "=0.1.41"
tracing-subscriber = { version = "=0.3.20", features = [
"fmt",
From 151c2d97f07e47cbce92593c83ae03c6193bd2a9 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 10 Dec 2025 14:13:24 +0000
Subject: [PATCH 21/60] [deps]: Update Rust crate tokio to v1.48.0 (#15700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 77 +++-----------------------
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 9 insertions(+), 70 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 1b98e677ac7..692ebfa29e9 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -2,21 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "addr2line"
-version = "0.24.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
-dependencies = [
- "gimli",
-]
-
-[[package]]
-name = "adler2"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
-
[[package]]
name = "aead"
version = "0.5.2"
@@ -351,21 +336,6 @@ dependencies = [
"windows-core",
]
-[[package]]
-name = "backtrace"
-version = "0.3.75"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
-dependencies = [
- "addr2line",
- "cfg-if",
- "libc",
- "miniz_oxide",
- "object",
- "rustc-demangle",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "base16ct"
version = "0.2.0"
@@ -1415,12 +1385,6 @@ dependencies = [
"polyval",
]
-[[package]]
-name = "gimli"
-version = "0.31.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
-
[[package]]
name = "glob"
version = "0.3.3"
@@ -1857,15 +1821,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-[[package]]
-name = "miniz_oxide"
-version = "0.8.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
-dependencies = [
- "adler2",
-]
-
[[package]]
name = "mio"
version = "1.0.3"
@@ -2177,15 +2132,6 @@ dependencies = [
"objc2-core-foundation",
]
-[[package]]
-name = "object"
-version = "0.36.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -2751,12 +2697,6 @@ dependencies = [
"winapi",
]
-[[package]]
-name = "rustc-demangle"
-version = "0.1.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
-
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3078,12 +3018,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
-version = "0.5.9"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -3299,11 +3239,10 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.45.0"
+version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
+checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
- "backtrace",
"bytes",
"libc",
"mio",
@@ -3313,14 +3252,14 @@ dependencies = [
"socket2",
"tokio-macros",
"tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
-version = "2.5.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index b492fc62e2a..2d5b1e31175 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -62,7 +62,7 @@ ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.37.2"
thiserror = "=2.0.17"
-tokio = "=1.45.0"
+tokio = "=1.48.0"
tokio-util = "=0.7.17"
tracing = "=0.1.41"
tracing-subscriber = { version = "=0.3.20", features = [
From 892f5548d21f63e64fedac03c94d540c83b9ef6d Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 10 Dec 2025 15:14:18 +0100
Subject: [PATCH 22/60] [deps] Platform: Update Rust crate bytes to v1.11.0
(#17618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
apps/desktop/desktop_native/Cargo.lock | 4 ++--
apps/desktop/desktop_native/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 692ebfa29e9..a2b653a929e 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -485,9 +485,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
-version = "1.10.1"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "camino"
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index 2d5b1e31175..58ce3758ae1 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -27,7 +27,7 @@ ashpd = "=0.11.0"
base64 = "=0.22.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
byteorder = "=1.5.0"
-bytes = "=1.10.1"
+bytes = "=1.11.0"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"
From 44384d51c9d2b2017603be94a54329b2df6cd8b3 Mon Sep 17 00:00:00 2001
From: Bryan Cunningham
Date: Wed, 10 Dec 2025 09:28:36 -0500
Subject: [PATCH 23/60] fix padding when nested. remove ng style and class
(#17874)
* fix padding when nested. remove ng style and class
* add expanded group to story to cover bug fix
* use class binding for async classes
* remove unnecessary x padding to improve truncation
* simplify padding logic
* fix padding end in closed state
* add back some padding in tree view
* add back padding to avoid weird spacing scenarios
---
.../src/navigation/nav-group.stories.ts | 5 ++++
.../src/navigation/nav-item.component.html | 27 ++++++++-----------
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts
index d5c381ac3e3..e3033e4b40a 100644
--- a/libs/components/src/navigation/nav-group.stories.ts
+++ b/libs/components/src/navigation/nav-group.stories.ts
@@ -84,6 +84,11 @@ export const Default: StoryObj = {
+
+
+
+
+
`,
}),
diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html
index 1de8a9bd167..8a59d474d94 100644
--- a/libs/components/src/navigation/nav-item.component.html
+++ b/libs/components/src/navigation/nav-item.component.html
@@ -2,16 +2,12 @@
@let open = sideNavService.open$ | async;
@if (open || icon()) {
@if (open) {
@@ -26,13 +22,12 @@
0 ? 'tw-py-0' : 'tw-py-2',
- open ? 'tw-pe-4' : 'tw-text-center',
- ]"
[title]="text()"
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
- [ngClass]="{ 'tw-justify-center': !open }"
+ [class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
+ [class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
+ [class.tw-text-center]="!open"
+ [class.tw-justify-center]="!open"
>
@if (icon()) {
0 }"
+ [class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
data-fvw
[routerLink]="route()"
[relativeTo]="relativeTo()"
@@ -74,7 +69,7 @@
|