From 94d94875475288de257b7a311ff0a1f577e8d1e6 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:31:08 -0400 Subject: [PATCH 01/36] [BRE-777] Fixing output to match what's in gh-actions (#14292) --- .github/workflows/deploy-web.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 9b890491282..1cde8dd636a 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -242,7 +242,7 @@ jobs: run: | # If run-id was used, get the commit from the download-latest-artifacts-run-id step if [ "${{ inputs.build-web-run-id }}" ]; then - echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT + echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then # If the download-latest-artifacts step failed, query the GH API to get the commit SHA of the artifact that was just built with trigger-build-web. @@ -251,7 +251,7 @@ jobs: else # Set the commit to the output of step download-latest-artifacts. - echo "commit=${{ steps.download-latest-artifacts.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT + echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT fi notify-start: From f74d7e5fd5d4a4602cea2c6afc55bc594d46ce1e Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 15 Apr 2025 14:17:53 -0400 Subject: [PATCH 02/36] [PM-20239] Initializing `nx` (#14276) * Add .nx file to .gitignore Co-authored-by: Addison Beck * Add nx package Co-authored-by: Addison Beck * Add nx.json file Co-authored-by: Addison Beck * Add nx to Platform ownership --------- Co-authored-by: Addison Beck --- .github/renovate.json5 | 1 + .gitignore | 3 + nx.json | 10 + package-lock.json | 528 ++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 5 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 nx.json diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 973ba700349..c4202ed2a68 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -250,6 +250,7 @@ "napi-derive", "node-forge", "node-ipc", + "nx", "oo7", "oslog", "pin-project", diff --git a/.gitignore b/.gitignore index d0d8edd596c..e865fa6a8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ storybook-static # Local app configuration apps/**/config/local.json + +# Nx +.nx diff --git a/nx.json b/nx.json new file mode 100644 index 00000000000..7da50182873 --- /dev/null +++ b/nx.json @@ -0,0 +1,10 @@ +{ + "cacheDirectory": ".nx/cache", + "defaultBase": "main", + "namedInputs": { + "default": ["{projectRoot}/**/*"], + "production": ["!{projectRoot}/**/*.spec.ts"] + }, + "parallel": 4, + "targetDefaults": {} +} diff --git a/package-lock.json b/package-lock.json index 3cba61db6f8..cb9baf4fafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,6 +156,7 @@ "json5": "2.2.3", "lint-staged": "15.4.1", "mini-css-extract-plugin": "2.9.2", + "nx": "20.8.0", "postcss": "8.5.1", "postcss-loader": "8.1.1", "prettier": "3.4.2", @@ -5954,6 +5955,34 @@ "node": "*" } }, + "node_modules/@emnapi/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.1.tgz", + "integrity": "sha512-4JFstCTaToCFrPqrGzgkF8N2NHjtsaY4uRh6brZQ5L9e4wbMieX8oDT8N7qfVFTQecHFEtkj4ve49VIZ3mKVqw==", + "dev": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.1.tgz", + "integrity": "sha512-LMshMVP0ZhACNjQNYXiU1iZJ6QCcv0lUdPDPugqGvCGXt5xtRVBPdtA0qU12pEXZzpWAhWlZYptfdAFq10DOVQ==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -8223,6 +8252,17 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", + "dev": true, + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, "node_modules/@ng-select/ng-select": { "version": "13.9.1", "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-13.9.1.tgz", @@ -8758,6 +8798,166 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/@nx/nx-darwin-arm64": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.8.0.tgz", + "integrity": "sha512-A6Te2KlINtcOo/depXJzPyjbk9E0cmgbom/sm/49XdQ8G94aDfyIIY1RIdwmDCK5NVd74KFG3JIByTk5+VnAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-darwin-x64": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.8.0.tgz", + "integrity": "sha512-UpqayUjgalArXaDvOoshqSelTrEp42cGDsZGy0sqpxwBpm3oPQ8wE1d7oBAmRo208rAxOuFP0LZRFUqRrwGvLA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-freebsd-x64": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.8.0.tgz", + "integrity": "sha512-dUR2fsLyKZYMHByvjy2zvmdMbsdXAiP+6uTlIAuu8eHMZ2FPQCAtt7lPYLwOFUxUXChbek2AJ+uCI0gRAgK/eg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm-gnueabihf": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.8.0.tgz", + "integrity": "sha512-GuZ7t0SzSX5ksLYva7koKZovQ5h/Kr1pFbOsQcBf3VLREBqFPSz6t7CVYpsIsMhiu/I3EKq6FZI3wDOJbee5uw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-gnu": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.8.0.tgz", + "integrity": "sha512-CiI955Q+XZmBBZ7cQqQg0MhGEFwZIgSpJnjPfWBt3iOYP8aE6nZpNOkmD7O8XcN/nEwwyeCOF8euXqEStwsk8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-musl": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.8.0.tgz", + "integrity": "sha512-Iy9DpvVisxsfNh4gOinmMQ4cLWdBlgvt1wmry1UwvcXg479p1oJQ1Kp1wksUZoWYqrAG8VPZUmkE0f7gjyHTGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-gnu": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.8.0.tgz", + "integrity": "sha512-kZrrXXzVSbqwmdTmQ9xL4Jhi0/FSLrePSxYCL9oOM3Rsj0lmo/aC9kz4NBv1ZzuqT7fumpBOnhqiL1QyhOWOeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-musl": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.8.0.tgz", + "integrity": "sha512-0l9jEMN8NhULKYCFiDF7QVpMMNG40duya+OF8dH0OzFj52N0zTsvsgLY72TIhslCB/cC74oAzsmWEIiFslscnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-arm64-msvc": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.8.0.tgz", + "integrity": "sha512-5miZJmRSwx1jybBsiB3NGocXL9TxGdT2D+dOqR2fsLklpGz0ItEWm8+i8lhDjgOdAr2nFcuQUfQMY57f9FOHrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-x64-msvc": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.8.0.tgz", + "integrity": "sha512-0P5r+bDuSNvoWys+6C1/KqGpYlqwSHpigCcyRzR62iZpT3OooZv+nWO06RlURkxMR8LNvYXTSSLvoLkjxqM8uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -10856,6 +11056,15 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -13196,6 +13405,59 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "license": "BSD-2-Clause" }, + "node_modules/@yarnpkg/parsers": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz", + "integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==", + "dev": true, + "dependencies": { + "js-yaml": "^3.10.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@yarnpkg/parsers/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/@zkochan/js-yaml": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz", + "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -14298,11 +14560,10 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "dev": true, - "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -18077,6 +18338,18 @@ "node": ">=10.13.0" } }, + "node_modules/enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -20259,6 +20532,43 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/front-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", + "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", + "dev": true, + "dependencies": { + "js-yaml": "^3.13.1" + } + }, + "node_modules/front-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/front-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/front-matter/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -28045,6 +28355,12 @@ "license": "MIT", "peer": true }, + "node_modules/node-machine-id": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", + "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", + "dev": true + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -28513,6 +28829,209 @@ "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "license": "MIT" }, + "node_modules/nx": { + "version": "20.8.0", + "resolved": "https://registry.npmjs.org/nx/-/nx-20.8.0.tgz", + "integrity": "sha512-+BN5B5DFBB5WswD8flDDTnr4/bf1VTySXOv60aUAllHqR+KS6deT0p70TTMZF4/A2n/L2UCWDaDro37MGaYozA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@napi-rs/wasm-runtime": "0.2.4", + "@yarnpkg/lockfile": "^1.1.0", + "@yarnpkg/parsers": "3.0.2", + "@zkochan/js-yaml": "0.0.7", + "axios": "^1.8.3", + "chalk": "^4.1.0", + "cli-cursor": "3.1.0", + "cli-spinners": "2.6.1", + "cliui": "^8.0.1", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "enquirer": "~2.3.6", + "figures": "3.2.0", + "flat": "^5.0.2", + "front-matter": "^4.0.2", + "ignore": "^5.0.4", + "jest-diff": "^29.4.1", + "jsonc-parser": "3.2.0", + "lines-and-columns": "2.0.3", + "minimatch": "9.0.3", + "node-machine-id": "1.1.12", + "npm-run-path": "^4.0.1", + "open": "^8.4.0", + "ora": "5.3.0", + "resolve.exports": "2.0.3", + "semver": "^7.5.3", + "string-width": "^4.2.3", + "tar-stream": "~2.2.0", + "tmp": "~0.2.1", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.3.0", + "yaml": "^2.6.0", + "yargs": "^17.6.2", + "yargs-parser": "21.1.1" + }, + "bin": { + "nx": "bin/nx.js", + "nx-cloud": "bin/nx-cloud.js" + }, + "optionalDependencies": { + "@nx/nx-darwin-arm64": "20.8.0", + "@nx/nx-darwin-x64": "20.8.0", + "@nx/nx-freebsd-x64": "20.8.0", + "@nx/nx-linux-arm-gnueabihf": "20.8.0", + "@nx/nx-linux-arm64-gnu": "20.8.0", + "@nx/nx-linux-arm64-musl": "20.8.0", + "@nx/nx-linux-x64-gnu": "20.8.0", + "@nx/nx-linux-x64-musl": "20.8.0", + "@nx/nx-win32-arm64-msvc": "20.8.0", + "@nx/nx-win32-x64-msvc": "20.8.0" + }, + "peerDependencies": { + "@swc-node/register": "^1.8.0", + "@swc/core": "^1.3.85" + }, + "peerDependenciesMeta": { + "@swc-node/register": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/nx/node_modules/cli-spinners": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", + "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nx/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/nx/node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/nx/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/nx/node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "node_modules/nx/node_modules/lines-and-columns": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", + "integrity": "sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/nx/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nx/node_modules/ora": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", + "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "log-symbols": "^4.0.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nx/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nx/node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/nx/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -31294,7 +31813,6 @@ "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" } diff --git a/package.json b/package.json index f24588c4c82..28d243e1c32 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "json5": "2.2.3", "lint-staged": "15.4.1", "mini-css-extract-plugin": "2.9.2", + "nx": "20.8.0", "postcss": "8.5.1", "postcss-loader": "8.1.1", "prettier": "3.4.2", From e3d1ef456e8dfaba8fbdac1a420f513a8acebd17 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 15 Apr 2025 14:37:12 -0400 Subject: [PATCH 03/36] [PM-14909] Add data/state for security task completion notification (#14279) * include tasks with notification cipher data * send security task information with update success message for notification * mark completed cipher updates with tasks as complete * refactor notification confirmation components and add stories * add keyhole icon * add conditional footer button to notification confirmation component * add external link icon * add external link icon to action button * add notification confirmation footer story * use keyhole icon if there are no additional security tasks to complete * add new message catalog entries to chrome.i18n * reimplement sending security task information with update success message for notification * open tasks in extension from confirmation notification button * update vault message key and dismiss all security tasks for a given cipher upon password update * resolve changes against updated main branch basis * put task fetching behind feature flag and update tests * cleanup * more cleanup --- .../abstractions/notification.background.ts | 1 + .../notification.background.spec.ts | 98 +++++++++++++- .../background/notification.background.ts | 123 ++++++++++++++++-- .../abstractions/notification-bar.ts | 7 +- apps/browser/src/autofill/notification/bar.ts | 6 +- ...rlay-notifications-content.service.spec.ts | 24 ++-- .../overlay-notifications-content.service.ts | 6 +- .../browser/src/background/main.background.ts | 23 ++-- 8 files changed, 247 insertions(+), 41 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 851f07576dd..6b3c91a109c 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -95,6 +95,7 @@ type NotificationBackgroundExtensionMessageHandlers = { unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; + bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAddLogin: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index ebdd244e140..ffc416ab62a 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; @@ -12,6 +12,7 @@ import { UserNotificationSettingsService } from "@bitwarden/common/autofill/serv import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -19,6 +20,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; +import { TaskService, SecurityTask } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum"; @@ -46,6 +48,8 @@ jest.mock("rxjs", () => { }); describe("NotificationBackground", () => { + const messagingService = mock(); + const taskService = mock(); let notificationBackground: NotificationBackground; const autofillService = mock(); const cipherService = mock(); @@ -88,6 +92,8 @@ describe("NotificationBackground", () => { policyService, themeStateService, userNotificationSettingsService, + taskService, + messagingService, ); }); @@ -201,8 +207,8 @@ describe("NotificationBackground", () => { await flushPromises(); expect(notificationBackground["handleSaveCipherMessage"]).toHaveBeenCalledWith( - message.data.commandToRetry.message, - message.data.commandToRetry.sender, + message.data?.commandToRetry?.message, + message.data?.commandToRetry?.sender, ); }); }); @@ -498,7 +504,7 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( null, "example.com", - message.data.newPassword, + message.data?.newPassword, sender.tab, true, ); @@ -570,7 +576,7 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( "cipher-id", "example.com", - message.data.newPassword, + message.data?.newPassword, sender.tab, ); }); @@ -618,7 +624,7 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( "cipher-id", "example.com", - message.data.newPassword, + message.data?.newPassword, sender.tab, ); }); @@ -844,6 +850,86 @@ describe("NotificationBackground", () => { ); }); + it("completes password update notification with a security task notice if any are present for the cipher, and dismisses tasks for the updated cipher", async () => { + const mockCipherId = "testId"; + const mockOrgId = "testOrgId"; + const mockSecurityTask = { + id: "testTaskId", + organizationId: mockOrgId, + cipherId: mockCipherId, + type: 0, + status: 0, + creationDate: new Date(), + revisionDate: new Date(), + } as SecurityTask; + const mockSecurityTask2 = { + ...mockSecurityTask, + id: "testTaskId2", + cipherId: "testId2", + } as SecurityTask; + taskService.tasksEnabled$.mockImplementation(() => of(true)); + taskService.pendingTasks$.mockImplementation(() => + of([mockSecurityTask, mockSecurityTask2]), + ); + jest + .spyOn(notificationBackground as any, "getNotificationFlag") + .mockResolvedValueOnce(true); + jest.spyOn(notificationBackground as any, "getOrgData").mockResolvedValueOnce([ + { + id: mockOrgId, + name: "Org Name, LLC", + productTierType: 3, + }, + ]); + + const tab = createChromeTabMock({ id: 1, url: "https://example.com" }); + const sender = mock({ tab }); + const message: NotificationBackgroundExtensionMessage = { + command: "bgSaveCipher", + edit: false, + folder: "folder-id", + }; + const queueMessage = mock({ + type: NotificationQueueMessageType.ChangePassword, + tab, + domain: "example.com", + newPassword: "newPassword", + }); + notificationBackground["notificationQueue"] = [queueMessage]; + const cipherView = mock({ + id: mockCipherId, + organizationId: mockOrgId, + login: { username: "testUser" }, + }); + getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); + + sendMockExtensionMessage(message, sender); + await flushPromises(); + + expect(editItemSpy).not.toHaveBeenCalled(); + expect(createWithServerSpy).not.toHaveBeenCalled(); + expect(updatePasswordSpy).toHaveBeenCalledWith( + cipherView, + queueMessage.newPassword, + message.edit, + sender.tab, + mockCipherId, + ); + expect(updateWithServerSpy).toHaveBeenCalled(); + expect(tabSendMessageDataSpy).toHaveBeenCalledWith( + sender.tab, + "saveCipherAttemptCompleted", + { + cipherId: "testId", + task: { + orgName: "Org Name, LLC", + remainingTasksCount: 1, + }, + username: "testUser", + }, + ); + }); + it("updates the cipher password if the queue message was locked and an existing cipher has the same username as the message", async () => { const tab = createChromeTabMock({ id: 1, url: "https://example.com" }); const sender = mock({ tab }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index c2e90460dfc..1f0cc469e2c 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, switchMap } from "rxjs"; +import { firstValueFrom, switchMap, map } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -22,16 +22,21 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { TaskService } from "@bitwarden/common/vault/tasks"; +import { SecurityTaskType } from "@bitwarden/common/vault/tasks/enums"; +import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task"; import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -70,6 +75,8 @@ export default class NotificationBackground { bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender), bgCloseNotificationBar: ({ message, sender }) => this.handleCloseNotificationBarMessage(message, sender), + bgOpenAtRisksPasswords: ({ message, sender }) => + this.handleOpenAtRisksPasswordsMessage(message, sender), bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(), bgGetDecryptedCiphers: () => this.getNotificationCipherData(), bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(), @@ -106,6 +113,8 @@ export default class NotificationBackground { private policyService: PolicyService, private themeStateService: ThemeStateService, private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, + private taskService: TaskService, + protected messagingService: MessagingService, ) {} init() { @@ -154,17 +163,20 @@ export default class NotificationBackground { firstValueFrom(this.domainSettingsService.showFavicons$), firstValueFrom(this.environmentService.environment$), ]); + const iconsServerUrl = env.getIconsUrl(); const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); + const decryptedCiphers = await this.cipherService.getAllDecryptedForUrl( - currentTab.url, + currentTab?.url, activeUserId, ); return decryptedCiphers.map((view) => { const { id, name, reprompt, favorite, login } = view; + return { id, name, @@ -599,13 +611,13 @@ export default class NotificationBackground { try { await this.cipherService.createWithServer(cipher); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - username: String(queueMessage?.username), - cipherId: String(cipher?.id), + username: queueMessage?.username && String(queueMessage.username), + cipherId: cipher?.id && String(cipher.id), }); await BrowserApi.tabSendMessage(tab, { command: "addedCipher" }); } catch (error) { await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - error: String(error.message), + error: error?.message && String(error.message), }); } } @@ -638,15 +650,49 @@ export default class NotificationBackground { return; } const cipher = await this.cipherService.encrypt(cipherView, userId); + + const shouldGetTasks = await this.getNotificationFlag(); + try { + const tasks = shouldGetTasks ? await this.getSecurityTasks(userId) : []; + const updatedCipherTask = tasks.find((task) => task.cipherId === cipherView?.id); + const cipherHasTask = !!updatedCipherTask?.id; + + let taskOrgName: string; + if (cipherHasTask && updatedCipherTask?.organizationId) { + const userOrgs = await this.getOrgData(); + taskOrgName = userOrgs.find(({ id }) => id === updatedCipherTask.organizationId)?.name; + } + + const taskData = cipherHasTask + ? { + remainingTasksCount: tasks.length - 1, + orgName: taskOrgName, + } + : undefined; + await this.cipherService.updateWithServer(cipher); + await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - username: String(cipherView?.login?.username), - cipherId: String(cipherView?.id), + username: cipherView?.login?.username && String(cipherView.login.username), + cipherId: cipherView?.id && String(cipherView.id), + task: taskData, }); + + // If the cipher had a security task, mark it as complete + if (cipherHasTask) { + // guard against multiple (redundant) security tasks per cipher + await Promise.all( + tasks.map((task) => { + if (task.cipherId === cipherView?.id) { + return this.taskService.markAsComplete(task.id, userId); + } + }), + ); + } } catch (error) { await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - error: String(error?.message), + error: error?.message && String(error.message), }); } } @@ -699,6 +745,32 @@ export default class NotificationBackground { return null; } + private async getSecurityTasks(userId: UserId) { + let tasks: SecurityTask[] = []; + + if (userId) { + tasks = await firstValueFrom( + this.taskService.tasksEnabled$(userId).pipe( + switchMap((tasksEnabled) => { + if (!tasksEnabled) { + return []; + } + + return this.taskService + .pendingTasks$(userId) + .pipe( + map((tasks) => + tasks.filter(({ type }) => type === SecurityTaskType.UpdateAtRiskCredential), + ), + ); + }), + ), + ); + } + + return tasks; + } + /** * Saves the current tab's domain to the never save list. * @@ -819,6 +891,41 @@ export default class NotificationBackground { }); } + /** + * Sends a message to the background to open the + * at-risk passwords extension view. Triggers + * notification closure as a side-effect. + * + * @param message - The extension message + * @param sender - The contextual sender of the message + */ + private async handleOpenAtRisksPasswordsMessage( + message: NotificationBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + const browserAction = BrowserApi.getBrowserAction(); + + try { + // Set route of the popup before attempting to open it. + // If the vault is locked, this won't have an effect as the auth guards will + // redirect the user to the login page. + await browserAction.setPopup({ popup: "popup/index.html#/at-risk-passwords" }); + + await Promise.all([ + this.messagingService.send(VaultMessages.OpenAtRiskPasswords), + BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar", { + fadeOutNotification: !!message.fadeOutNotification, + }), + ]); + } finally { + // Reset the popup route to the default route so any subsequent + // popup openings will not open to the at-risk-passwords page. + await browserAction.setPopup({ + popup: "popup/index.html#/", + }); + } + } + /** * Sends a message back to the sender tab which triggers * an CSS adjustment of the notification bar. diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index 7e2fdab04d3..cbfeffcf2f4 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -29,11 +29,14 @@ type NotificationBarIframeInitData = { }; type NotificationBarWindowMessage = { - cipherId?: string; command: string; + data?: { + cipherId?: string; + task?: NotificationTaskInfo; + username?: string; + }; error?: string; initData?: NotificationBarIframeInitData; - username?: string; }; type NotificationBarWindowMessageHandlers = { diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index f544e75527c..d660790ee63 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -356,7 +356,8 @@ function openViewVaultItemPopout(e: Event, cipherId: string) { function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) { const { theme, type } = notificationBarIframeInitData; - const { error, username, cipherId } = message; + const { error, data } = message; + const { username, cipherId, task } = data || {}; const i18n = getI18n(); const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light); @@ -371,8 +372,9 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) { i18n, error, username: username ?? i18n.typeLogin, + task, handleOpenVault: (e) => cipherId && openViewVaultItemPopout(e, cipherId), - handleOpenTasks: () => {}, + handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRisksPasswords" }), }), document.body, ); diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.spec.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.spec.ts index 8a8ccdf363b..28db10b35fa 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.spec.ts @@ -23,8 +23,8 @@ describe("OverlayNotificationsContentService", () => { autofillInit = new AutofillInit( domQueryService, domElementVisibilityService, - null, - null, + undefined, + undefined, overlayNotificationsContentService, ); autofillInit.init(); @@ -89,7 +89,7 @@ describe("OverlayNotificationsContentService", () => { await flushPromises(); expect( - overlayNotificationsContentService["notificationBarIframeElement"].style.transform, + overlayNotificationsContentService["notificationBarIframeElement"]?.style.transform, ).toBe("translateX(100%)"); }); @@ -103,12 +103,12 @@ describe("OverlayNotificationsContentService", () => { }); await flushPromises(); - overlayNotificationsContentService["notificationBarIframeElement"].dispatchEvent( + overlayNotificationsContentService["notificationBarIframeElement"]?.dispatchEvent( new Event("load"), ); expect( - overlayNotificationsContentService["notificationBarIframeElement"].style.transform, + overlayNotificationsContentService["notificationBarIframeElement"]?.style.transform, ).toBe("translateX(0)"); }); @@ -134,7 +134,7 @@ describe("OverlayNotificationsContentService", () => { globalThis.dispatchEvent( new MessageEvent("message", { data: { command: "initNotificationBar" }, - source: overlayNotificationsContentService["notificationBarIframeElement"].contentWindow, + source: overlayNotificationsContentService["notificationBarIframeElement"]?.contentWindow, }), ); await flushPromises(); @@ -168,9 +168,9 @@ describe("OverlayNotificationsContentService", () => { data: { fadeOutNotification: true }, }); - expect(overlayNotificationsContentService["notificationBarIframeElement"].style.opacity).toBe( - "0", - ); + expect( + overlayNotificationsContentService["notificationBarIframeElement"]?.style.opacity, + ).toBe("0"); jest.advanceTimersByTime(150); @@ -210,7 +210,7 @@ describe("OverlayNotificationsContentService", () => { data: { height: 1000 }, }); - expect(overlayNotificationsContentService["notificationBarElement"].style.height).toBe( + expect(overlayNotificationsContentService["notificationBarElement"]?.style.height).toBe( "1000px", ); }); @@ -236,13 +236,13 @@ describe("OverlayNotificationsContentService", () => { sendMockExtensionMessage({ command: "saveCipherAttemptCompleted", - data: { error: "" }, + data: { error: undefined }, }); expect( overlayNotificationsContentService["notificationBarIframeElement"].contentWindow .postMessage, - ).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: "" }, "*"); + ).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: undefined }, "*"); }); }); diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts index 662ec624dc4..519521feaa9 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts +++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts @@ -135,9 +135,13 @@ export class OverlayNotificationsContentService * @private */ private handleSaveCipherAttemptCompletedMessage(message: NotificationsExtensionMessage) { + // destructure error out of data + const { error, ...otherData } = message?.data || {}; + this.sendMessageToNotificationBarIframe({ command: "saveCipherAttemptCompleted", - error: message.data?.error, + data: Object.keys(otherData).length ? otherData : undefined, + error, }); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a5001e0c5b7..c437698f525 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1186,6 +1186,17 @@ export default class MainBackground { this.authService, () => this.generatePasswordToClipboard(), ); + + this.taskService = new DefaultTaskService( + this.stateProvider, + this.apiService, + this.organizationService, + this.configService, + this.authService, + this.notificationsService, + messageListener, + ); + this.notificationBackground = new NotificationBackground( this.accountService, this.authService, @@ -1200,6 +1211,8 @@ export default class MainBackground { this.policyService, this.themeStateService, this.userNotificationSettingsService, + this.taskService, + this.messagingService, ); this.overlayNotificationsBackground = new OverlayNotificationsBackground( @@ -1304,16 +1317,6 @@ export default class MainBackground { this.configService, ); - this.taskService = new DefaultTaskService( - this.stateProvider, - this.apiService, - this.organizationService, - this.configService, - this.authService, - this.notificationsService, - messageListener, - ); - this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); From 4cddc40828920914776a05fc906b087ac3bfcf12 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 15 Apr 2025 14:39:48 -0400 Subject: [PATCH 04/36] remove inlineAutofillMenuRefreshAddEditCipher message (#13805) --- .../src/autofill/background/overlay.background.spec.ts | 5 ----- apps/browser/src/autofill/background/overlay.background.ts | 1 - .../background/overlay.background.deprecated.spec.ts | 3 --- .../deprecated/background/overlay.background.deprecated.ts | 1 - 4 files changed, 10 deletions(-) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 3085dbc2f8d..0fe4a459048 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -117,7 +117,6 @@ describe("OverlayBackground", () => { let getFrameDetailsSpy: jest.SpyInstance; let tabsSendMessageSpy: jest.SpyInstance; let tabSendMessageDataSpy: jest.SpyInstance; - let sendMessageSpy: jest.SpyInstance; let getTabFromCurrentWindowIdSpy: jest.SpyInstance; let getTabSpy: jest.SpyInstance; let openUnlockPopoutSpy: jest.SpyInstance; @@ -228,7 +227,6 @@ describe("OverlayBackground", () => { tabSendMessageDataSpy = jest .spyOn(BrowserApi, "tabSendMessageData") .mockImplementation(() => Promise.resolve()); - sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); getTabSpy = jest.spyOn(BrowserApi, "getTab"); openUnlockPopoutSpy = jest.spyOn(overlayBackground as any, "openUnlockPopout"); @@ -1553,7 +1551,6 @@ describe("OverlayBackground", () => { await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); }); @@ -1579,7 +1576,6 @@ describe("OverlayBackground", () => { await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); }); @@ -1618,7 +1614,6 @@ describe("OverlayBackground", () => { await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher"); expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled(); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index fa0ae9b9b3e..a2088f50a11 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -2434,7 +2434,6 @@ export class OverlayBackground implements OverlayBackgroundInterface { cipherId: cipherView.id, cipherType: addNewCipherType, }); - await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } catch (error) { this.logService.error("Error building cipher and opening add/edit vault item popout", error); } diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts index 128dd189878..68f8032350e 100644 --- a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts @@ -647,9 +647,6 @@ describe("OverlayBackground", () => { await flushPromises(); expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); - expect(BrowserApi.sendMessage).toHaveBeenCalledWith( - "inlineAutofillMenuRefreshAddEditCipher", - ); expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts index d0fad4cd00e..c9eb442d75d 100644 --- a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts @@ -678,7 +678,6 @@ class LegacyOverlayBackground implements OverlayBackgroundInterface { ); await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); - await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } /** From 8258ea39b0b33eaabf8320cea98b64208b96f931 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 15 Apr 2025 12:17:41 -0700 Subject: [PATCH 05/36] [PM-18903] Desktop sync issues (#13681) * [PM-18707] Use different BroadcasterSubscriptionId in base view component to avoid collision with desktop view component * [PM-18707] Use userId instead of payloadUserId for cipher notification syncs * [PM-19032] Live Sync on Desktop (#13851) * migrate the vault-items to an observables rather than async/promises - this helps keep data in sync with the service state and avoids race conditions * migrate the view component to an observables rather than async/promises - this helps keep data in sync with the service state and avoids race conditions * decrypt saved cipher from server * bump timeout for upserting ciphers * mark `go` as async in desktop vault - previously it was a floating promise * Revert "mark `go` as async in desktop vault" This reverts commit fd28f40b187c39fb30d1d1ab2972d398b2673419. * Revert "bump timeout for upserting ciphers" This reverts commit e963acc377b0018fb1f90d4e9d181959820e00b3. * move vault utilities to `common` rather than `lib` to avoid circular dependencies * use `perUserCache$` for `cipherViews$` to avoid new subscriptions from being created * use userId from observable rather than locally set to be the most up to date * [PM-18707] Add clearBuffer$ input to perUserCache$ helper so that the internal share replay buffers can be cleared * [PM-18707] Rework forceCipherViews$ to clearBuffer$ refactor - Add dependency for cipherDecryptionKeys$ for the cipherViews so that decryption is never attempted without keys * [PM-18707] Add overload to perUserCache to satisfy type checker * [PM-18707] Fix overloads * [PM-18707] Add check for empty failed to decrypt ciphers * [PM-18707] Mark vault component for check after observable emits. The cipherViews$ observable now persists between subscriptions, meaning that updates via the sync push notifications can occur outside the AngularZone causing delays in updating the view. --------- Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Co-authored-by: Nick Krantz --- .../vault/app/vault/vault-items.component.ts | 3 - .../src/vault/app/vault/view.component.ts | 4 +- .../vault/individual-vault/vault.component.ts | 14 +- .../vault/components/add-edit.component.ts | 9 +- .../vault/components/vault-items.component.ts | 111 +++++++----- .../src/vault/components/view.component.ts | 167 +++++++++++------- .../internal/default-notifications.service.ts | 4 +- .../src/vault/services/cipher.service.ts | 64 +++---- .../tasks/services/default-task.service.ts | 5 +- .../src/vault/utils/observable-utilities.ts | 26 ++- 10 files changed, 239 insertions(+), 168 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/vault-items.component.ts b/apps/desktop/src/vault/app/vault/vault-items.component.ts index b7a45bd2467..d5838459ff7 100644 --- a/apps/desktop/src/vault/app/vault/vault-items.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items.component.ts @@ -27,9 +27,6 @@ export class VaultItemsComponent extends BaseVaultItemsComponent { // eslint-disable-next-line rxjs-angular/prefer-takeuntil searchBarService.searchText$.pipe(distinctUntilChanged()).subscribe((searchText) => { this.searchText = searchText; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.search(200); }); } diff --git a/apps/desktop/src/vault/app/vault/view.component.ts b/apps/desktop/src/vault/app/vault/view.component.ts index 9ddf18fff93..e5f677cbca6 100644 --- a/apps/desktop/src/vault/app/vault/view.component.ts +++ b/apps/desktop/src/vault/app/vault/view.component.ts @@ -126,9 +126,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro } async ngOnChanges() { - await super.load(); - - if (this.cipher.decryptionFailure) { + if (this.cipher?.decryptionFailure) { DecryptionFailureDialogComponent.open(this.dialogService, { cipherIds: [this.cipherId as CipherId], }); 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 c2173e29ee0..7055f164a53 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -13,6 +13,7 @@ import { Subject, } from "rxjs"; import { + catchError, concatMap, debounceTime, filter, @@ -23,7 +24,6 @@ import { take, takeUntil, tap, - catchError, } from "rxjs/operators"; import { @@ -64,6 +64,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; import { AddEditFolderDialogComponent, @@ -138,7 +139,6 @@ const SearchTextDebounceInterval = 200; VaultFilterModule, VaultItemsModule, SharedModule, - DecryptionFailureDialogComponent, ], providers: [ RoutedVaultFilterService, @@ -348,9 +348,8 @@ export class VaultComponent implements OnInit, OnDestroy { ]).pipe( filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), concatMap(async ([ciphers, filter, searchText]) => { - const failedCiphers = await firstValueFrom( - this.cipherService.failedToDecryptCiphers$(activeUserId), - ); + const failedCiphers = + (await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? []; const filterFunction = createFilterFunction(filter); // Append any failed to decrypt ciphers to the top of the cipher list const allCiphers = [...failedCiphers, ...ciphers]; @@ -472,6 +471,7 @@ export class VaultComponent implements OnInit, OnDestroy { firstSetup$ .pipe( switchMap(() => this.cipherService.failedToDecryptCiphers$(activeUserId)), + filterOutNullish(), map((ciphers) => ciphers.filter((c) => !c.isDeleted)), filter((ciphers) => ciphers.length > 0), take(1), @@ -528,6 +528,10 @@ export class VaultComponent implements OnInit, OnDestroy { this.isEmpty = collections?.length === 0 && ciphers?.length === 0; this.performingInitialLoad = false; this.refreshing = false; + + // Explicitly mark for check to ensure the view is updated + // Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS notifications) + this.changeDetectorRef.markForCheck(); }, ); } diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 2393863bb5f..b9defa8383d 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -422,10 +422,15 @@ export class AddEditComponent implements OnInit, OnDestroy { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.encryptCipher(activeUserId); + try { this.formPromise = this.saveCipher(cipher); - await this.formPromise; - this.cipher.id = cipher.id; + const savedCipher = await this.formPromise; + + // Reset local cipher from the saved cipher returned from the server + this.cipher = await savedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId), + ); this.toastService.showToast({ variant: "success", title: null, diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index f7280cb74b3..852302cc0c4 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,13 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + BehaviorSubject, + Subject, + combineLatest, + filter, + from, + of, + switchMap, + takeUntil, +} from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -21,17 +30,17 @@ export class VaultItemsComponent implements OnInit, OnDestroy { loaded = false; ciphers: CipherView[] = []; - filter: (cipher: CipherView) => boolean = null; deleted = false; organization: Organization; protected searchPending = false; - private userId: UserId; + /** Construct filters as an observable so it can be appended to the cipher stream. */ + private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null); private destroy$ = new Subject(); - private searchTimeout: any = null; private isSearchable: boolean = false; private _searchText$ = new BehaviorSubject(""); + get searchText() { return this._searchText$.value; } @@ -39,18 +48,28 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this._searchText$.next(value); } + get filter() { + return this._filter$.value; + } + + set filter(value: (cipher: CipherView) => boolean | null) { + this._filter$.next(value); + } + constructor( protected searchService: SearchService, protected cipherService: CipherService, protected accountService: AccountService, - ) {} + ) { + this.subscribeToCiphers(); + } async ngOnInit() { - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - this._searchText$ + combineLatest([getUserId(this.accountService.activeAccount$), this._searchText$]) .pipe( - switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))), + switchMap(([userId, searchText]) => + from(this.searchService.isSearchable(userId, searchText)), + ), takeUntil(this.destroy$), ) .subscribe((isSearchable) => { @@ -80,23 +99,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy { async applyFilter(filter: (cipher: CipherView) => boolean = null) { this.filter = filter; - await this.search(null); - } - - async search(timeout: number = null, indexedCiphers?: CipherView[]) { - this.searchPending = false; - if (this.searchTimeout != null) { - clearTimeout(this.searchTimeout); - } - if (timeout == null) { - await this.doSearch(indexedCiphers); - return; - } - this.searchPending = true; - this.searchTimeout = setTimeout(async () => { - await this.doSearch(indexedCiphers); - this.searchPending = false; - }, timeout); } selectCipher(cipher: CipherView) { @@ -121,25 +123,44 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; - protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) { - // Get userId from activeAccount if not provided from parent stream - if (!userId) { - userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - } + /** + * Creates stream of dependencies that results in the list of ciphers to display + * within the vault list. + * + * Note: This previously used promises but race conditions with how the ciphers were + * stored in electron. Using observables is more reliable as fresh values will always + * cascade through the components. + */ + private subscribeToCiphers() { + getUserId(this.accountService.activeAccount$) + .pipe( + switchMap((userId) => + combineLatest([ + this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), + this.cipherService.failedToDecryptCiphers$(userId), + this._searchText$, + this._filter$, + of(userId), + ]), + ), + switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => { + let allCiphers = indexedCiphers ?? []; + const _failedCiphers = failedCiphers ?? []; - indexedCiphers = - indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId))); + allCiphers = [..._failedCiphers, ...allCiphers]; - const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId)); - if (failedCiphers != null && failedCiphers.length > 0) { - indexedCiphers = [...failedCiphers, ...indexedCiphers]; - } - - this.ciphers = await this.searchService.searchCiphers( - this.userId, - this.searchText, - [this.filter, this.deletedFilter], - indexedCiphers, - ); + return this.searchService.searchCiphers( + userId, + searchText, + [filter, this.deletedFilter], + allCiphers, + ); + }), + takeUntilDestroyed(), + ) + .subscribe((ciphers) => { + this.ciphers = ciphers; + this.loaded = true; + }); } } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 0dda3c593b7..6b6f24f4217 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -11,7 +11,17 @@ import { OnInit, Output, } from "@angular/core"; -import { filter, firstValueFrom, map, Observable } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + filter, + firstValueFrom, + map, + Observable, + of, + switchMap, + tap, +} from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -46,11 +56,22 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { PasswordRepromptService } from "@bitwarden/vault"; -const BroadcasterSubscriptionId = "ViewComponent"; +const BroadcasterSubscriptionId = "BaseViewComponent"; @Directive() export class ViewComponent implements OnDestroy, OnInit { - @Input() cipherId: string; + /** Observable of cipherId$ that will update each time the `Input` updates */ + private _cipherId$ = new BehaviorSubject(null); + + @Input() + set cipherId(value: string) { + this._cipherId$.next(value); + } + + get cipherId(): string { + return this._cipherId$.getValue(); + } + @Input() collectionId: string; @Output() onEditCipher = new EventEmitter(); @Output() onCloneCipher = new EventEmitter(); @@ -126,13 +147,30 @@ export class ViewComponent implements OnDestroy, OnInit { switch (message.command) { case "syncCompleted": if (message.successfully) { - await this.load(); this.changeDetectorRef.detectChanges(); } break; } }); }); + + // Set up the subscription to the activeAccount$ and cipherId$ observables + combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$]) + .pipe( + tap(() => this.cleanUp()), + switchMap(([userId, cipherId]) => { + const cipher$ = this.cipherService.cipherViews$(userId).pipe( + map((ciphers) => ciphers?.find((c) => c.id === cipherId)), + filter((cipher) => !!cipher), + ); + return combineLatest([of(userId), cipher$]); + }), + ) + .subscribe(([userId, cipher]) => { + this.cipher = cipher; + + void this.constructCipherDetails(userId); + }); } ngOnDestroy() { @@ -140,70 +178,6 @@ export class ViewComponent implements OnDestroy, OnInit { this.cleanUp(); } - async load() { - this.cleanUp(); - - // Grab individual cipher from `cipherViews$` for the most up-to-date information - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - this.cipher = await firstValueFrom( - this.cipherService.cipherViews$(activeUserId).pipe( - map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)), - filter((cipher) => !!cipher), - ), - ); - - this.canAccessPremium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), - ); - this.showPremiumRequiredTotp = - this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; - this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ - this.collectionId as CollectionId, - ]); - this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher); - - if (this.cipher.folderId) { - this.folder = await ( - await firstValueFrom(this.folderService.folderViews$(activeUserId)) - ).find((f) => f.id == this.cipher.folderId); - } - - const canGenerateTotp = - this.cipher.type === CipherType.Login && - this.cipher.login.totp && - (this.cipher.organizationUseTotp || this.canAccessPremium); - - this.totpInfo$ = canGenerateTotp - ? this.totpService.getCode$(this.cipher.login.totp).pipe( - map((response) => { - const epoch = Math.round(new Date().getTime() / 1000.0); - const mod = epoch % response.period; - - // Format code - const totpCodeFormatted = - response.code.length > 4 - ? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}` - : response.code; - - return { - totpCode: response.code, - totpCodeFormatted, - totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"), - totpSec: response.period - mod, - totpLow: response.period - mod <= 7, - } as TotpInfo; - }), - ) - : undefined; - - if (this.previousCipherId !== this.cipherId) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId); - } - this.previousCipherId = this.cipherId; - } - async edit() { this.onEditCipher.emit(this.cipher); } @@ -533,4 +507,61 @@ export class ViewComponent implements OnDestroy, OnInit { this.showCardCode = false; this.passwordReprompted = false; } + + /** + * When a cipher is viewed, construct all details for the view that are not directly + * available from the cipher object itself. + */ + private async constructCipherDetails(userId: UserId) { + this.canAccessPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + ); + this.showPremiumRequiredTotp = + this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.collectionId as CollectionId, + ]); + this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher); + + if (this.cipher.folderId) { + this.folder = await ( + await firstValueFrom(this.folderService.folderViews$(userId)) + ).find((f) => f.id == this.cipher.folderId); + } + + const canGenerateTotp = + this.cipher.type === CipherType.Login && + this.cipher.login.totp && + (this.cipher.organizationUseTotp || this.canAccessPremium); + + this.totpInfo$ = canGenerateTotp + ? this.totpService.getCode$(this.cipher.login.totp).pipe( + map((response) => { + const epoch = Math.round(new Date().getTime() / 1000.0); + const mod = epoch % response.period; + + // Format code + const totpCodeFormatted = + response.code.length > 4 + ? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}` + : response.code; + + return { + totpCode: response.code, + totpCodeFormatted, + totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"), + totpSec: response.period - mod, + totpLow: response.period - mod <= 7, + } as TotpInfo; + }), + ) + : undefined; + + if (this.previousCipherId !== this.cipherId) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId); + } + this.previousCipherId = this.cipherId; + } } diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 423b3370455..40c93f8f22a 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -153,14 +153,14 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract await this.syncService.syncUpsertCipher( notification.payload as SyncCipherNotification, notification.type === NotificationType.SyncCipherUpdate, - payloadUserId, + userId, ); break; case NotificationType.SyncCipherDelete: case NotificationType.SyncLoginDelete: await this.syncService.syncDeleteCipher( notification.payload as SyncCipherNotification, - payloadUserId, + userId, ); break; case NotificationType.SyncFolderCreate: diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8d4146eaaba..c192876c83e 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,17 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { - combineLatest, - filter, - firstValueFrom, - map, - merge, - Observable, - of, - shareReplay, - Subject, - switchMap, -} from "rxjs"; +import { combineLatest, filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; import { SemVer } from "semver"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -40,6 +29,7 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { StateProvider } from "../../platform/state"; import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid"; import { OrgKey, UserKey } from "../../types/key"; +import { perUserCache$ } from "../../vault/utils/observable-utilities"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; @@ -91,11 +81,12 @@ export class CipherService implements CipherServiceAbstraction { this.sortCiphersByLastUsed, ); /** - * Observable that forces the `cipherViews$` observable to re-emit with the provided value. - * Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the active user. + * Observable that forces the `cipherViews$` observable for the given user to emit a null value. + * Used to let subscribers of `cipherViews$` know that the decrypted ciphers have been cleared for the user and to + * clear them from the shareReplay buffer created in perUserCache$(). * @private */ - private forceCipherViews$: Subject = new Subject(); + private clearCipherViewsForUser$: Subject = new Subject(); constructor( private keyService: KeyService, @@ -132,13 +123,16 @@ export class CipherService implements CipherServiceAbstraction { * A `null` value indicates that the latest encrypted ciphers have not been decrypted yet and that * decryption is in progress. The latest decrypted ciphers will be emitted once decryption is complete. */ - cipherViews$(userId: UserId): Observable { - return combineLatest([this.encryptedCiphersState(userId).state$, this.localData$(userId)]).pipe( - filter(([ciphers]) => ciphers != null), // Skip if ciphers haven't been loaded yor synced yet - switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted(userId))), - shareReplay({ bufferSize: 1, refCount: true }), + cipherViews$ = perUserCache$((userId: UserId): Observable => { + return combineLatest([ + this.encryptedCiphersState(userId).state$, + this.localData$(userId), + this.keyService.cipherDecryptionKeys$(userId, true), + ]).pipe( + filter(([ciphers, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet + switchMap(() => this.getAllDecrypted(userId)), ); - } + }, this.clearCipherViewsForUser$); addEditCipherInfo$(userId: UserId): Observable { return this.addEditCipherInfoState(userId).state$; @@ -149,13 +143,11 @@ export class CipherService implements CipherServiceAbstraction { * * An empty array indicates that all ciphers were successfully decrypted. */ - failedToDecryptCiphers$(userId: UserId): Observable { + failedToDecryptCiphers$ = perUserCache$((userId: UserId): Observable => { return this.failedToDecryptCiphersState(userId).state$.pipe( filter((ciphers) => ciphers != null), - switchMap((ciphers) => merge(this.forceCipherViews$, of(ciphers))), - shareReplay({ bufferSize: 1, refCount: true }), ); - } + }, this.clearCipherViewsForUser$); async setDecryptedCipherCache(value: CipherView[], userId: UserId) { // Sometimes we might prematurely decrypt the vault and that will result in no ciphers @@ -190,10 +182,8 @@ export class CipherService implements CipherServiceAbstraction { userId ??= activeUserId; await this.clearDecryptedCiphersState(userId); - // Force the cipherView$ observable (which always tracks the active user) to re-emit - if (userId == activeUserId) { - this.forceCipherViews$.next(null); - } + // Force the cached cipherView$ observable(s) to emit a null value + this.clearCipherViewsForUser$.next(userId); } async encrypt( @@ -402,10 +392,14 @@ export class CipherService implements CipherServiceAbstraction { return await this.getDecryptedCiphers(userId); } - const [newDecCiphers, failedCiphers] = await this.decryptCiphers( - await this.getAll(userId), - userId, - ); + const decrypted = await this.decryptCiphers(await this.getAll(userId), userId); + + // We failed to decrypt, return empty array but do not cache + if (decrypted == null) { + return []; + } + + const [newDecCiphers, failedCiphers] = decrypted; await this.setDecryptedCipherCache(newDecCiphers, userId); await this.setFailedDecryptedCiphers(failedCiphers, userId); @@ -429,12 +423,12 @@ export class CipherService implements CipherServiceAbstraction { private async decryptCiphers( ciphers: Cipher[], userId: UserId, - ): Promise<[CipherView[], CipherView[]]> { + ): Promise<[CipherView[], CipherView[]] | null> { const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true)); if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) { // return early if there are no keys to decrypt with - return [[], []]; + return null; } // Group ciphers by orgId or under 'null' for the user's ciphers diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts index 016eed2e7d6..7386102263c 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.ts @@ -12,8 +12,11 @@ import { MessageListener } from "@bitwarden/common/platform/messaging"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; +import { + filterOutNullish, + perUserCache$, +} from "@bitwarden/common/vault/utils/observable-utilities"; -import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities"; import { TaskService } from "../abstractions/task.service"; import { SecurityTaskStatus } from "../enums"; import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models"; diff --git a/libs/common/src/vault/utils/observable-utilities.ts b/libs/common/src/vault/utils/observable-utilities.ts index bb559c600d3..cdec51fc953 100644 --- a/libs/common/src/vault/utils/observable-utilities.ts +++ b/libs/common/src/vault/utils/observable-utilities.ts @@ -1,20 +1,38 @@ -import { filter, Observable, OperatorFunction, shareReplay } from "rxjs"; +import { EMPTY, filter, map, merge, Observable, OperatorFunction, shareReplay } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; /** * Builds an observable once per userId and caches it for future requests. * The built observables are shared among subscribers with a replay buffer size of 1. + * + * Optionally, a clearBuffer$ observable can be provided to clear the replay buffer for a specific or all userIds. * @param create - A function that creates an observable for a given userId. + * @param clearBuffer$ - An observable that, when emitted, clears the buffer for the emitted userId. When null is emitted, all caches are cleared. */ export function perUserCache$( create: (userId: UserId) => Observable, -): (userId: UserId) => Observable { - const cache = new Map>(); + clearBuffer$: Observable, +): (userId: UserId) => Observable; +export function perUserCache$( + create: (userId: UserId) => Observable, +): (userId: UserId) => Observable; +export function perUserCache$( + create: (userId: UserId) => Observable, + clearBuffer$: Observable | undefined = undefined, +): (userId: UserId) => Observable { + const cache = new Map>(); return (userId: UserId) => { let observable = cache.get(userId); if (!observable) { - observable = create(userId).pipe(shareReplay({ bufferSize: 1, refCount: false })); + clearBuffer$ ??= EMPTY; + observable = merge( + create(userId), + clearBuffer$.pipe( + filter((clearId) => clearId === userId || clearId === null), + map(() => null), + ), + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); cache.set(userId, observable); } return observable; From b66430b25cb68fe3f8a99b6f1d2631a3807a199e Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Tue, 15 Apr 2025 16:36:05 -0400 Subject: [PATCH 06/36] [PM-19781] Lit Components icons cleanup (#14294) * update icon shapes to match new design system icons * add AngleUpIcon to storybook * rename Family icon to Users to match design system naming conventions * add Collection icon * move illustrations to their own path/category to match design system convention * remove hardcoded PartyHorn illustration size * fix swapped story names * rename PartyHorn illustration to Celebrate to match design system convention * update Warning illustration to use new design system shape --- .../cipher/cipher-indicator-icons.ts | 4 +- .../content/components/icons/angle-down.ts | 4 +- .../content/components/icons/angle-up.ts | 9 +--- .../content/components/icons/business.ts | 19 ++------ .../content/components/icons/close.ts | 4 +- .../content/components/icons/collection.ts | 23 ++++++++++ .../components/icons/exclamation-triangle.ts | 14 +++++- .../content/components/icons/family.ts | 18 -------- .../content/components/icons/folder.ts | 5 ++- .../content/components/icons/globe.ts | 5 +-- .../content/components/icons/index.ts | 6 +-- .../content/components/icons/pencil-square.ts | 8 +++- .../content/components/icons/shield.ts | 4 +- .../autofill/content/components/icons/user.ts | 4 +- .../content/components/icons/users.ts | 18 ++++++++ .../content/components/icons/warning.ts | 23 ---------- .../celebrate.ts} | 12 ++--- .../content/components/illustrations/index.ts | 3 ++ .../{icons => illustrations}/keyhole.ts | 0 .../components/illustrations/warning.ts | 22 ++++++++++ .../lit-stories/.lit-docs/icons.mdx | 10 ++--- ... => cipher-indicator-icons.lit-stories.ts} | 2 +- .../lit-stories/icons/icons.lit-stories.ts | 10 ++--- .../illustrations.lit-stories.ts | 44 +++++++++++++++++++ .../components/notification/button-row.ts | 4 +- .../notification/confirmation/body.ts | 4 +- 26 files changed, 168 insertions(+), 111 deletions(-) create mode 100644 apps/browser/src/autofill/content/components/icons/collection.ts delete mode 100644 apps/browser/src/autofill/content/components/icons/family.ts create mode 100644 apps/browser/src/autofill/content/components/icons/users.ts delete mode 100644 apps/browser/src/autofill/content/components/icons/warning.ts rename apps/browser/src/autofill/content/components/{icons/party-horn.ts => illustrations/celebrate.ts} (98%) create mode 100644 apps/browser/src/autofill/content/components/illustrations/index.ts rename apps/browser/src/autofill/content/components/{icons => illustrations}/keyhole.ts (100%) create mode 100644 apps/browser/src/autofill/content/components/illustrations/warning.ts rename apps/browser/src/autofill/content/components/lit-stories/ciphers/{cipher-indicator-icon.lit-stories.ts => cipher-indicator-icons.lit-stories.ts} (94%) create mode 100644 apps/browser/src/autofill/content/components/lit-stories/illustrations/illustrations.lit-stories.ts diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts index 39d4dd28f24..9096149f510 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts @@ -4,7 +4,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; import { themes } from "../../../content/components/constants/styles"; -import { Business, Family } from "../../../content/components/icons"; +import { Business, Users } from "../../../content/components/icons"; // @TODO connect data source to icon checks // @TODO support other indicator types (attachments, etc) @@ -19,7 +19,7 @@ export function CipherInfoIndicatorIcons({ }) { const indicatorIcons = [ ...(showBusinessIcon ? [Business({ color: themes[theme].text.muted, theme })] : []), - ...(showFamilyIcon ? [Family({ color: themes[theme].text.muted, theme })] : []), + ...(showFamilyIcon ? [Users({ color: themes[theme].text.muted, theme })] : []), ]; return indicatorIcons.length diff --git a/apps/browser/src/autofill/content/components/icons/angle-down.ts b/apps/browser/src/autofill/content/components/icons/angle-down.ts index db5275aafa9..27cd5ab81c5 100644 --- a/apps/browser/src/autofill/content/components/icons/angle-down.ts +++ b/apps/browser/src/autofill/content/components/icons/angle-down.ts @@ -8,10 +8,10 @@ export function AngleDown({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/angle-up.ts b/apps/browser/src/autofill/content/components/icons/angle-up.ts index 7344123d5ad..f8bda632285 100644 --- a/apps/browser/src/autofill/content/components/icons/angle-up.ts +++ b/apps/browser/src/autofill/content/components/icons/angle-up.ts @@ -8,15 +8,10 @@ export function AngleUp({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/business.ts b/apps/browser/src/autofill/content/components/icons/business.ts index ef8e082c21f..79e64a0a1f9 100644 --- a/apps/browser/src/autofill/content/components/icons/business.ts +++ b/apps/browser/src/autofill/content/components/icons/business.ts @@ -8,30 +8,17 @@ export function Business({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + - - `; } diff --git a/apps/browser/src/autofill/content/components/icons/close.ts b/apps/browser/src/autofill/content/components/icons/close.ts index c9d9286ca3f..27610bc7773 100644 --- a/apps/browser/src/autofill/content/components/icons/close.ts +++ b/apps/browser/src/autofill/content/components/icons/close.ts @@ -8,10 +8,10 @@ export function Close({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/collection.ts b/apps/browser/src/autofill/content/components/icons/collection.ts new file mode 100644 index 00000000000..fb2c58647c5 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/collection.ts @@ -0,0 +1,23 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { IconProps } from "../common-types"; +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Collection({ color, disabled, theme }: IconProps) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts index d87d5621e30..c4f587b2d7b 100644 --- a/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts +++ b/apps/browser/src/autofill/content/components/icons/exclamation-triangle.ts @@ -8,10 +8,20 @@ export function ExclamationTriangle({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + + + `; diff --git a/apps/browser/src/autofill/content/components/icons/family.ts b/apps/browser/src/autofill/content/components/icons/family.ts deleted file mode 100644 index 9870c5d37c0..00000000000 --- a/apps/browser/src/autofill/content/components/icons/family.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { css } from "@emotion/css"; -import { html } from "lit"; - -import { IconProps } from "../common-types"; -import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; - -export function Family({ color, disabled, theme }: IconProps) { - const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; - - return html` - - - - `; -} diff --git a/apps/browser/src/autofill/content/components/icons/folder.ts b/apps/browser/src/autofill/content/components/icons/folder.ts index 84577aef820..1b93d2d32ea 100644 --- a/apps/browser/src/autofill/content/components/icons/folder.ts +++ b/apps/browser/src/autofill/content/components/icons/folder.ts @@ -8,10 +8,11 @@ export function Folder({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/globe.ts b/apps/browser/src/autofill/content/components/icons/globe.ts index fc0a975284d..936fd8d2802 100644 --- a/apps/browser/src/autofill/content/components/icons/globe.ts +++ b/apps/browser/src/autofill/content/components/icons/globe.ts @@ -8,11 +8,10 @@ export function Globe({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts index 4b6cb7abdd8..de39b70ab24 100644 --- a/apps/browser/src/autofill/content/components/icons/index.ts +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -3,14 +3,12 @@ export { AngleUp } from "./angle-up"; export { BrandIconContainer } from "./brand-icon-container"; export { Business } from "./business"; export { Close } from "./close"; +export { Collection } from "./collection"; export { ExclamationTriangle } from "./exclamation-triangle"; export { ExternalLink } from "./external-link"; -export { Family } from "./family"; export { Folder } from "./folder"; export { Globe } from "./globe"; -export { Keyhole } from "./keyhole"; -export { PartyHorn } from "./party-horn"; export { PencilSquare } from "./pencil-square"; export { Shield } from "./shield"; export { User } from "./user"; -export { Warning } from "./warning"; +export { Users } from "./users"; diff --git a/apps/browser/src/autofill/content/components/icons/pencil-square.ts b/apps/browser/src/autofill/content/components/icons/pencil-square.ts index f41ab927809..11366f2631a 100644 --- a/apps/browser/src/autofill/content/components/icons/pencil-square.ts +++ b/apps/browser/src/autofill/content/components/icons/pencil-square.ts @@ -8,10 +8,14 @@ export function PencilSquare({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + + `; diff --git a/apps/browser/src/autofill/content/components/icons/shield.ts b/apps/browser/src/autofill/content/components/icons/shield.ts index 5a2d7d39d58..a027dd3e113 100644 --- a/apps/browser/src/autofill/content/components/icons/shield.ts +++ b/apps/browser/src/autofill/content/components/icons/shield.ts @@ -8,10 +8,10 @@ export function Shield({ color, theme }: IconProps) { const shapeColor = color || themes[theme].brandLogo; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/user.ts b/apps/browser/src/autofill/content/components/icons/user.ts index 32ccd3a2031..b59204a0ad8 100644 --- a/apps/browser/src/autofill/content/components/icons/user.ts +++ b/apps/browser/src/autofill/content/components/icons/user.ts @@ -8,10 +8,10 @@ export function User({ color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + `; diff --git a/apps/browser/src/autofill/content/components/icons/users.ts b/apps/browser/src/autofill/content/components/icons/users.ts new file mode 100644 index 00000000000..eb7840104f0 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/users.ts @@ -0,0 +1,18 @@ +import { css } from "@emotion/css"; +import { html } from "lit"; + +import { IconProps } from "../common-types"; +import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; + +export function Users({ color, disabled, theme }: IconProps) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/icons/warning.ts b/apps/browser/src/autofill/content/components/icons/warning.ts deleted file mode 100644 index 9ae9aeca352..00000000000 --- a/apps/browser/src/autofill/content/components/icons/warning.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { html } from "lit"; - -// This icon has static multi-colors for each theme -export function Warning() { - return html` - - - - - - `; -} diff --git a/apps/browser/src/autofill/content/components/icons/party-horn.ts b/apps/browser/src/autofill/content/components/illustrations/celebrate.ts similarity index 98% rename from apps/browser/src/autofill/content/components/icons/party-horn.ts rename to apps/browser/src/autofill/content/components/illustrations/celebrate.ts index 439d60a79de..30b3743004f 100644 --- a/apps/browser/src/autofill/content/components/icons/party-horn.ts +++ b/apps/browser/src/autofill/content/components/illustrations/celebrate.ts @@ -5,16 +5,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { IconProps } from "../common-types"; // This icon has static multi-colors for each theme -export function PartyHorn({ theme }: IconProps) { +export function Celebrate({ theme }: IconProps) { if (theme === ThemeTypes.Dark) { return html` - + + + + + + + `; +} diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/icons.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/icons.mdx index f85cf3ae90f..571ed10285a 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/icons.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/icons.mdx @@ -2,7 +2,7 @@ import { Meta, Controls } from "@storybook/addon-docs"; import * as stories from "./icons.lit-stories"; - + ## Icon Stories @@ -14,12 +14,12 @@ like size, color, and theme. Each story is an example of how a specific icon can | | | | ------------------------- | ------------------ | -| `AngleDownIcon` | `FolderIcon` | -| `BusinessIcon` | `GlobeIcon` | -| `BrandIcon` | `PartyHornIcon` | +| `AngleDownIcon` | `AngleUpIcon` | +| `BusinessIcon` | `FolderIcon` | +| `BrandIcon` | `GlobeIcon` | | `CloseIcon` | `PencilSquareIcon` | | `ExclamationTriangleIcon` | `ShieldIcon` | -| `FamilyIcon` | `UserIcon` | +| `UsersIcon` | `UserIcon` | ## Props diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icons.lit-stories.ts similarity index 94% rename from apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts rename to apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icons.lit-stories.ts index 89c3ecbcb1c..08530452730 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icon.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-indicator-icons.lit-stories.ts @@ -12,7 +12,7 @@ type Args = { }; export default { - title: "Components/Ciphers/Cipher Indicator Icon", + title: "Components/Ciphers/Cipher Indicator Icons", argTypes: { showBusinessIcon: { control: "boolean" }, showFamilyIcon: { control: "boolean" }, diff --git a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts index 8bd87ef6674..fc5db1c7c2c 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts @@ -14,7 +14,7 @@ type Args = { }; export default { - title: "Components/Icons/Icons", + title: "Components/Icons", argTypes: { iconLink: { control: "text" }, color: { control: "color" }, @@ -53,16 +53,16 @@ const createIconStory = (iconName: keyof typeof Icons): StoryObj => { }; export const AngleDownIcon = createIconStory("AngleDown"); -export const BusinessIcon = createIconStory("Business"); +export const AngleUpIcon = createIconStory("AngleUp"); export const BrandIcon = createIconStory("BrandIconContainer"); +export const BusinessIcon = createIconStory("Business"); export const CloseIcon = createIconStory("Close"); +export const CollectionIcon = createIconStory("Collection"); export const ExclamationTriangleIcon = createIconStory("ExclamationTriangle"); export const ExternalLinkIcon = createIconStory("ExternalLink"); -export const FamilyIcon = createIconStory("Family"); export const FolderIcon = createIconStory("Folder"); export const GlobeIcon = createIconStory("Globe"); -export const KeyholeIcon = createIconStory("Keyhole"); -export const PartyHornIcon = createIconStory("PartyHorn"); export const PencilSquareIcon = createIconStory("PencilSquare"); export const ShieldIcon = createIconStory("Shield"); export const UserIcon = createIconStory("User"); +export const UsersIcon = createIconStory("Users"); diff --git a/apps/browser/src/autofill/content/components/lit-stories/illustrations/illustrations.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/illustrations/illustrations.lit-stories.ts new file mode 100644 index 00000000000..86d55f2f795 --- /dev/null +++ b/apps/browser/src/autofill/content/components/lit-stories/illustrations/illustrations.lit-stories.ts @@ -0,0 +1,44 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; + +import * as Illustrations from "../../illustrations"; + +type Args = { + theme: Theme; + size: number; +}; + +export default { + title: "Components/Illustrations", + argTypes: { + theme: { control: "select", options: [...Object.values(ThemeTypes)] }, + size: { control: "number", min: 10, max: 100, step: 1 }, + }, + args: { + theme: ThemeTypes.Light, + size: 50, + }, +} as Meta; + +const Template = ( + args: Args, + IllustrationComponent: (props: Args) => ReturnType, +) => html` +
+ ${IllustrationComponent({ ...args })} +
+`; + +const createIllustrationStory = (illustrationName: keyof typeof Illustrations): StoryObj => { + return { + render: (args) => Template(args, Illustrations[illustrationName]), + } as StoryObj; +}; + +export const KeyholeIllustration = createIllustrationStory("Keyhole"); +export const CelebrateIllustration = createIllustrationStory("Celebrate"); +export const WarningIllustration = createIllustrationStory("Warning"); diff --git a/apps/browser/src/autofill/content/components/notification/button-row.ts b/apps/browser/src/autofill/content/components/notification/button-row.ts index 8661f5957e1..6fa32f11aa2 100644 --- a/apps/browser/src/autofill/content/components/notification/button-row.ts +++ b/apps/browser/src/autofill/content/components/notification/button-row.ts @@ -4,14 +4,14 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { Theme } from "@bitwarden/common/platform/enums"; import { Option, OrgView, FolderView } from "../common-types"; -import { Business, Family, Folder, User } from "../icons"; +import { Business, Users, Folder, User } from "../icons"; import { ButtonRow } from "../rows/button-row"; function getVaultIconByProductTier(productTierType?: ProductTierType): Option["icon"] { switch (productTierType) { case ProductTierType.Free: case ProductTierType.Families: - return Family; + return Users; case ProductTierType.Teams: case ProductTierType.Enterprise: case ProductTierType.TeamsStarter: diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/body.ts b/apps/browser/src/autofill/content/components/notification/confirmation/body.ts index 55d257b36f4..d2ac7f36277 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/body.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/body.ts @@ -4,7 +4,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; import { themes } from "../../constants/styles"; -import { PartyHorn, Keyhole, Warning } from "../../icons"; +import { Celebrate, Keyhole, Warning } from "../../illustrations"; import { NotificationConfirmationMessage } from "./message"; @@ -33,7 +33,7 @@ export function NotificationConfirmationBody({ theme, handleOpenVault, }: NotificationConfirmationBodyProps) { - const IconComponent = tasksAreComplete ? Keyhole : !error ? PartyHorn : Warning; + const IconComponent = tasksAreComplete ? Keyhole : !error ? Celebrate : Warning; const showConfirmationMessage = confirmationMessage || buttonText || messageDetails; From a61d8780816c0c57710f76ff331e1635b15e247a Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Tue, 15 Apr 2025 17:19:58 -0400 Subject: [PATCH 07/36] PM-20106 Pass indicator data to notification bar cipher items (#14246) * PM-20106 initial approach whihc preserves exisiting indicator file style * refactored approach to be able to pass any icon when or if needed in the future * address feedback --- .../background/notification.background.ts | 31 ++++++++++++++-- .../cipher/cipher-indicator-icons.ts | 35 +++++++++++-------- .../content/components/cipher/cipher-info.ts | 16 ++++++--- .../content/components/cipher/types.ts | 9 +++++ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 1f0cc469e2c..6589252d94b 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -16,6 +16,7 @@ import { } from "@bitwarden/common/autofill/constants"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -41,7 +42,11 @@ import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; import { openAddEditVaultItemPopout } from "../../vault/popup/utils/vault-popout-window"; -import { NotificationCipherData } from "../content/components/cipher/types"; +import { + OrganizationCategory, + OrganizationCategories, + NotificationCipherData, +} from "../content/components/cipher/types"; import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; @@ -174,8 +179,29 @@ export default class NotificationBackground { activeUserId, ); + const organizations = await firstValueFrom( + this.organizationService.organizations$(activeUserId), + ); + return decryptedCiphers.map((view) => { - const { id, name, reprompt, favorite, login } = view; + const { id, name, reprompt, favorite, login, organizationId } = view; + + const organizationType = organizationId + ? organizations.find((org) => org.id === organizationId)?.productTierType + : null; + + const organizationCategories: OrganizationCategory[] = []; + + if ( + [ProductTierType.Teams, ProductTierType.Enterprise, ProductTierType.TeamsStarter].includes( + organizationType, + ) + ) { + organizationCategories.push(OrganizationCategories.business); + } + if ([ProductTierType.Families, ProductTierType.Free].includes(organizationType)) { + organizationCategories.push(OrganizationCategories.family); + } return { id, @@ -183,6 +209,7 @@ export default class NotificationBackground { type: CipherType.Login, reprompt, favorite, + ...(organizationCategories.length ? { organizationCategories } : {}), icon: buildCipherIcon(iconsServerUrl, view, showFavicons), login: login && { username: login.username, diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts index 9096149f510..e4fe012a678 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-indicator-icons.ts @@ -1,30 +1,35 @@ import { css } from "@emotion/css"; -import { html } from "lit"; +import { html, TemplateResult } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; import { themes } from "../../../content/components/constants/styles"; import { Business, Users } from "../../../content/components/icons"; -// @TODO connect data source to icon checks -// @TODO support other indicator types (attachments, etc) +import { OrganizationCategories, OrganizationCategory } from "./types"; + +const cipherIndicatorIconsMap: Record< + OrganizationCategory, + (args: { color: string; theme: Theme }) => TemplateResult +> = { + [OrganizationCategories.business]: Business, + [OrganizationCategories.family]: Users, +}; + export function CipherInfoIndicatorIcons({ - showBusinessIcon, - showFamilyIcon, + organizationCategories = [], theme, }: { - showBusinessIcon?: boolean; - showFamilyIcon?: boolean; + organizationCategories?: OrganizationCategory[]; theme: Theme; }) { - const indicatorIcons = [ - ...(showBusinessIcon ? [Business({ color: themes[theme].text.muted, theme })] : []), - ...(showFamilyIcon ? [Users({ color: themes[theme].text.muted, theme })] : []), - ]; - - return indicatorIcons.length - ? html` ${indicatorIcons} ` - : null; // @TODO null case should be handled by parent + return html` + + ${organizationCategories.map((name) => + cipherIndicatorIconsMap[name]?.({ color: themes[theme].text.muted, theme }), + )} + + `; } const cipherInfoIndicatorIconsStyles = css` diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-info.ts b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts index 6ff32353938..e3d237b9bc6 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-info.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-info.ts @@ -1,5 +1,5 @@ import { css } from "@emotion/css"; -import { html } from "lit"; +import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; @@ -8,14 +8,22 @@ import { themes, typography } from "../../../content/components/constants/styles import { CipherInfoIndicatorIcons } from "./cipher-indicator-icons"; import { NotificationCipherData } from "./types"; -// @TODO support other cipher types (card, identity, notes, etc) export function CipherInfo({ cipher, theme }: { cipher: NotificationCipherData; theme: Theme }) { - const { name, login } = cipher; + const { name, login, organizationCategories } = cipher; + const hasIndicatorIcons = organizationCategories?.length; return html`
- ${[name, CipherInfoIndicatorIcons({ theme })]} + ${[ + name, + hasIndicatorIcons + ? CipherInfoIndicatorIcons({ + theme, + organizationCategories, + }) + : nothing, + ]} ${login?.username diff --git a/apps/browser/src/autofill/content/components/cipher/types.ts b/apps/browser/src/autofill/content/components/cipher/types.ts index ff29f9b559f..590311682bf 100644 --- a/apps/browser/src/autofill/content/components/cipher/types.ts +++ b/apps/browser/src/autofill/content/components/cipher/types.ts @@ -14,6 +14,14 @@ export const CipherRepromptTypes = { type CipherRepromptType = (typeof CipherRepromptTypes)[keyof typeof CipherRepromptTypes]; +export type OrganizationCategory = + (typeof OrganizationCategories)[keyof typeof OrganizationCategories]; + +export const OrganizationCategories = { + business: "business", + family: "family", +} as const; + export type WebsiteIconData = { imageEnabled: boolean; image: string; @@ -50,4 +58,5 @@ export type NotificationCipherData = BaseCipherData & login?: { username: string; }; + organizationCategories?: OrganizationCategory[]; }; From cb869484239c01075e6145681c240c8c43f21a4c Mon Sep 17 00:00:00 2001 From: Miles Blackwood Date: Tue, 15 Apr 2025 20:00:08 -0400 Subject: [PATCH 08/36] [PM-15436] Standalone password entry should trigger save to bitwarden prompt. (#14110) * Modify behavior so standalone password entry (with or without generator) should trigger save to bitwarden prompt. * Rename intent to action, extend button/action styles. * Ensure font weight is returned to normal. * Make save login message a button to handle accessibility, adds helper function. * Fix failing snapshot by reintigrating erroneously removed line. * Update snapshot to match new saveLoginButton. * Add add'l open in new window message to aria label. * Update snapshot with open in new window message. --- apps/browser/src/_locales/en/messages.json | 4 +- .../autofill/background/overlay.background.ts | 6 +-- .../autofill-inline-menu-list.spec.ts.snap | 39 ++------------- .../list/autofill-inline-menu-list.spec.ts | 10 ++-- .../pages/list/autofill-inline-menu-list.ts | 50 +++++++++++++++---- .../overlay/inline-menu/pages/list/list.scss | 8 +++ 6 files changed, 61 insertions(+), 56 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index f3b85496b75..87b94650b51 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4928,8 +4928,8 @@ "message": "Password regenerated", "description": "Notification message for when a password has been regenerated" }, - "saveLoginToBitwarden": { - "message": "Save login to Bitwarden?", + "saveToBitwarden": { + "message": "Save to Bitwarden", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index a2088f50a11..4e2e773a0c7 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1852,7 +1852,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Verifies whether the save login inline menu view should be shown. This requires that - * the login data on the page contains a username and either a current or new password. + * the login data on the page contains either a current or new password. * * @param tab - The tab to check for login data */ @@ -1869,7 +1869,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { return ( (this.shouldShowInlineMenuAccountCreation() || this.focusedFieldMatchesFillType(InlineMenuFillType.PasswordGeneration)) && - !!(loginData.username && (loginData.password || loginData.newPassword)) + !!(loginData.password || loginData.newPassword) ); } @@ -2157,7 +2157,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { "passwordRegenerated", "passwords", "regeneratePassword", - "saveLoginToBitwarden", + "saveToBitwarden", "toggleBitwardenVaultOverlay", "totpCodeAria", "totpSecondsSpanAria", diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index acd06fb8c65..b6e41c448d6 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -4,47 +4,14 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList creates the build sav
- `; diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts index b1eebd2bc39..ed28375e4fe 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts @@ -1089,12 +1089,12 @@ describe("AutofillInlineMenuList", () => { }); describe("displaying the save login view", () => { - let buildSaveLoginInlineMenuListSpy: jest.SpyInstance; + let buildSaveLoginInlineMenuSpy: jest.SpyInstance; beforeEach(() => { - buildSaveLoginInlineMenuListSpy = jest.spyOn( + buildSaveLoginInlineMenuSpy = jest.spyOn( autofillInlineMenuList as any, - "buildSaveLoginInlineMenuList", + "buildSaveLoginInlineMenu", ); }); @@ -1108,7 +1108,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage({ command: "showSaveLoginInlineMenuList" }); - expect(buildSaveLoginInlineMenuListSpy).not.toHaveBeenCalled(); + expect(buildSaveLoginInlineMenuSpy).not.toHaveBeenCalled(); }); it("builds the save login item view", async () => { @@ -1117,7 +1117,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage({ command: "showSaveLoginInlineMenuList" }); - expect(buildSaveLoginInlineMenuListSpy).toHaveBeenCalled(); + expect(buildSaveLoginInlineMenuSpy).toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index acb01594cc6..e0db93b6b4a 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -3,6 +3,8 @@ import "@webcomponents/custom-elements"; import "lit/polyfill-support.js"; +import { FocusableElement } from "tabbable"; + import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EVENTS, UPDATE_PASSKEYS_HEADINGS_ON_SCROLL } from "@bitwarden/common/autofill/constants"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; @@ -117,7 +119,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { } if (showSaveLoginMenu) { - this.buildSaveLoginInlineMenuList(); + this.buildSaveLoginInlineMenu(); return; } @@ -165,24 +167,52 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { /** * Builds the inline menu list as a prompt that asks the user if they'd like to save the login data. */ - private buildSaveLoginInlineMenuList() { - const saveLoginMessage = globalThis.document.createElement("div"); - saveLoginMessage.classList.add("save-login", "inline-menu-list-message"); - saveLoginMessage.textContent = this.getTranslation("saveLoginToBitwarden"); + private buildSaveLoginInlineMenu() { + const saveLoginButton = globalThis.document.createElement("button"); + saveLoginButton.classList.add( + "save-login", + "inline-menu-list-button", + "inline-menu-list-action", + ); + + saveLoginButton.tabIndex = -1; + saveLoginButton.setAttribute( + "aria-label", + `${this.getTranslation("saveToBitwarden")}, ${this.getTranslation("opensInANewWindow")}`, + ); + saveLoginButton.textContent = this.getTranslation("saveToBitwarden"); + + saveLoginButton.addEventListener(EVENTS.CLICK, this.handleNewLoginVaultItemAction); + saveLoginButton.addEventListener(EVENTS.KEYUP, this.handleSaveLoginInlineMenuKeyUp); + + const inlineMenuListButtonContainer = this.buildButtonContainer(saveLoginButton); - const newItemButton = this.buildNewItemButton(true); this.showInlineMenuAccountCreation = true; - this.inlineMenuListContainer.append(saveLoginMessage, newItemButton); + this.inlineMenuListContainer.append(inlineMenuListButtonContainer); } + private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => { + const listenedForKeys = new Set(["ArrowDown"]); + if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + return; + } + + event.preventDefault(); + + if (event.code === "ArrowDown") { + (event.target as FocusableElement).focus(); + return; + } + }; + /** * Handles the show save login inline menu list message that is triggered from the background script. */ private handleShowSaveLoginInlineMenuList() { if (this.authStatus === AuthenticationStatus.Unlocked) { this.resetInlineMenuContainer(); - this.buildSaveLoginInlineMenuList(); + this.buildSaveLoginInlineMenu(); } } @@ -521,7 +551,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.newItemButtonElement.textContent = this.getNewItemButtonText(showLogin); this.newItemButtonElement.setAttribute("aria-label", this.getNewItemAriaLabel(showLogin)); this.newItemButtonElement.prepend(buildSvgDomElement(plusIcon)); - this.newItemButtonElement.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick); + this.newItemButtonElement.addEventListener(EVENTS.CLICK, this.handleNewLoginVaultItemAction); return this.buildButtonContainer(this.newItemButtonElement); } @@ -581,7 +611,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the new item button. * Sends a message to the parent window to add a new vault item. */ - private handeNewItemButtonClick = () => { + private handleNewLoginVaultItemAction = () => { let addNewCipherType = this.inlineMenuFillType; if (this.showInlineMenuAccountCreation) { diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index d0875cfe427..93f5f647ffe 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -45,6 +45,14 @@ body * { &.no-items, &.save-login { font-size: 1.6rem; + &:has(:focus-visible) { + outline-width: 0.2rem; + outline-style: solid; + + @include themify($themes) { + outline-color: themed("focusOutlineColor"); + } + } } } From 9da15601be3b8a7e2883db55b7ab0a0a8150ccb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Wed, 16 Apr 2025 15:06:41 +0200 Subject: [PATCH 09/36] Add workflow to trigger self-host unified build in publish web (#14268) --- .github/workflows/publish-web.yml | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 09f5ddc6318..69b29086d36 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -141,3 +141,36 @@ jobs: - name: Log out of Docker run: docker logout + + self-host-unified-build: + name: Trigger self-host unified build + runs-on: ubuntu-22.04 + needs: + - setup + steps: + - name: Log in to Azure - CI subscription + uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve GitHub PAT secrets + id: retrieve-secret-pat + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" + + - name: Trigger self-host build + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'bitwarden', + repo: 'self-host', + workflow_id: 'build-unified.yml', + ref: 'main', + inputs: { + use_latest_core_version: true + } + }); From 9cffc3b4f4c61aa57dcc62b2298f4840e3906a18 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 16 Apr 2025 08:16:40 -0500 Subject: [PATCH 10/36] [PM-20118] Capitalize risk insights (#14291) --- apps/web/src/locales/en/messages.json | 4 ++-- .../access-intelligence/risk-insights-loading.component.html | 2 +- .../tools/access-intelligence/risk-insights.component.html | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0193fc4862b..85a7b8cb927 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4073,8 +4073,8 @@ "updateBrowser": { "message": "Update browser" }, - "generatingRiskInsights": { - "message": "Generating your risk insights..." + "generatingYourRiskInsights": { + "message": "Generating your Risk Insights..." }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html index 4e77838229e..0c5b74eead2 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights-loading.component.html @@ -4,5 +4,5 @@ title="{{ 'loading' | i18n }}" aria-hidden="true" > -

{{ "generatingRiskInsights" | i18n }}

+

{{ "generatingYourRiskInsights" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html index 397e2a630de..2d5693dad54 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.html @@ -8,6 +8,7 @@ {{ "reviewAtRiskPasswords" | i18n }}
Date: Wed, 16 Apr 2025 11:04:31 -0400 Subject: [PATCH 11/36] fix restore button (#14244) --- apps/desktop/src/vault/app/vault/view.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/vault/app/vault/view.component.html b/apps/desktop/src/vault/app/vault/view.component.html index ede6eb7ed82..8477a588fef 100644 --- a/apps/desktop/src/vault/app/vault/view.component.html +++ b/apps/desktop/src/vault/app/vault/view.component.html @@ -656,7 +656,11 @@ class="primary" (click)="restore()" appA11yTitle="{{ 'restore' | i18n }}" - *ngIf="(limitItemDeletion$ | async) ? (canRestoreCipher$ | async) : cipher.isDeleted" + *ngIf=" + (limitItemDeletion$ | async) + ? (canRestoreCipher$ | async) && cipher.isDeleted + : cipher.isDeleted + " > From b413272bd5adc63b2c77bdeceddf23d7f0603208 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Wed, 16 Apr 2025 11:08:51 -0400 Subject: [PATCH 12/36] [PM-20325] - Misc design fixes/tweaks (#14309) * fix icon sizing in option selection * fix close button vertical centering * fix cipher item update text * fix missing header background color * fix brand logo positioning in notification header --- .../autofill/content/components/buttons/close-button.ts | 1 + .../components/buttons/option-selection-button.ts | 5 +++-- .../autofill/content/components/cipher/cipher-action.ts | 4 ++-- .../content/components/icons/brand-icon-container.ts | 9 +++++++-- .../autofill/content/components/notification/header.ts | 2 +- .../content/components/option-selection/option-item.ts | 7 ++++--- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/browser/src/autofill/content/components/buttons/close-button.ts b/apps/browser/src/autofill/content/components/buttons/close-button.ts index c32d0c130e3..05a12d4f453 100644 --- a/apps/browser/src/autofill/content/components/buttons/close-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/close-button.ts @@ -35,5 +35,6 @@ const closeButtonStyles = (theme: Theme) => css` > svg { width: 20px; height: 20px; + vertical-align: middle; } `; diff --git a/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts b/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts index cf9a561ee39..e3c7e0d54e6 100644 --- a/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts @@ -44,7 +44,7 @@ export function OptionSelectionButton({ `; } -const iconSize = "15px"; +const iconSize = "16px"; const selectionButtonStyles = ({ disabled, @@ -94,7 +94,8 @@ const selectionButtonStyles = ({ > svg { max-width: ${iconSize}; - height: fit-content; + max-height: ${iconSize}; + height: auto; } `; diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts index 2d386d34d6a..aaa4b11d8a2 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts @@ -19,13 +19,13 @@ export function CipherAction({ ? BadgeButton({ buttonAction: handleAction, // @TODO localize - buttonText: "Update item", + buttonText: "Update", theme, }) : EditButton({ buttonAction: handleAction, // @TODO localize - buttonText: "Edit item", + buttonText: "Edit", theme, }); } diff --git a/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts b/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts index 8df68d79b6e..1b08f261eb6 100644 --- a/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts +++ b/apps/browser/src/autofill/content/components/icons/brand-icon-container.ts @@ -12,8 +12,13 @@ export function BrandIconContainer({ iconLink, theme }: { iconLink?: URL; theme: } const brandIconContainerStyles = css` + display: flex; + justify-content: center; + width: 24px; + height: 24px; + > svg { - width: 20px; - height: fit-content; + width: auto; + height: 100%; } `; diff --git a/apps/browser/src/autofill/content/components/notification/header.ts b/apps/browser/src/autofill/content/components/notification/header.ts index 50c2c629942..d6cedf6a85a 100644 --- a/apps/browser/src/autofill/content/components/notification/header.ts +++ b/apps/browser/src/autofill/content/components/notification/header.ts @@ -49,7 +49,7 @@ const notificationHeaderStyles = ({ display: flex; align-items: center; justify-content: flex-start; - background-color: ${themes[theme].background}; + background-color: ${themes[theme].background.DEFAULT}; padding: 12px 16px 8px 16px; white-space: nowrap; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-item.ts b/apps/browser/src/autofill/content/components/option-selection/option-item.ts index 619d77e63d3..e8a293e2c3f 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-item.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-item.ts @@ -62,14 +62,15 @@ const optionItemStyles = css` `; const optionItemIconContainerStyles = css` + display: flex; flex-grow: 1; flex-shrink: 1; - width: ${optionItemIconWidth}px; - height: ${optionItemIconWidth}px; + max-width: ${optionItemIconWidth}px; + max-height: ${optionItemIconWidth}px; > svg { width: 100%; - height: fit-content; + height: auto; } `; From f293c15f4d8ae9bdae07e1f8a26232da2c456243 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 16 Apr 2025 08:24:30 -0700 Subject: [PATCH 13/36] [PM-19538] Add shareReplay to internal orgKeys subscription (#14034) --- .../collections/services/default-collection.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.ts b/libs/admin-console/src/common/collections/services/default-collection.service.ts index da50a25886e..1ae58d3eef3 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs"; import { Jsonify } from "type-fest"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -8,10 +8,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ActiveUserState, - StateProvider, COLLECTION_DATA, DeriveDefinition, DerivedState, + StateProvider, UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -84,6 +84,7 @@ export class DefaultCollectionService implements CollectionService { switchMap(([userId, collectionData]) => combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), ), + shareReplay({ refCount: false, bufferSize: 1 }), ); this.decryptedCollectionDataState = this.stateProvider.getDerived( From db16c98a1d93114b613398f0758c15bdbc7faf20 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:58:54 -0400 Subject: [PATCH 14/36] [PM-17773] Added "Sponsored Families" dropdown nav item in the admin console (#14029) * Added nav item for f4e in org admin console * shotgun surgery for adding "useAdminSponsoredFamilies" feature from the org table * Resolved issue with members nav item also being selected when f4e is selected * Separated out billing's logic from the org layout component * Removed unused observable * Moved logic to existing f4e policy service and added unit tests * Resolved script typescript error * Resolved goofy switchMap --------- Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> --- .../organization-layout.component.html | 26 ++- .../layouts/organization-layout.component.ts | 5 + .../members/members-routing.module.ts | 9 + .../free-families-policy.service.spec.ts | 193 ++++++++++++++++++ .../services/free-families-policy.service.ts | 46 +++++ .../models/data/organization.data.spec.ts | 1 + .../models/data/organization.data.ts | 2 + .../models/domain/organization.ts | 2 + .../response/profile-organization.response.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 2 + 10 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/billing/services/free-families-policy.service.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 47846c77571..e50c55e83d2 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -19,12 +19,26 @@ *ngIf="canShowVaultTab(organization)" > - + + + + + + + + + + + + + ; protected isBreadcrumbEventLogsEnabled$: Observable; + protected showSponsoredFamiliesDropdown$: Observable; constructor( private route: ActivatedRoute, @@ -76,6 +78,7 @@ export class OrganizationLayoutComponent implements OnInit { private providerService: ProviderService, protected bannerService: AccountDeprovisioningBannerService, private accountService: AccountService, + private freeFamiliesPolicyService: FreeFamiliesPolicyService, ) {} async ngOnInit() { @@ -92,6 +95,8 @@ export class OrganizationLayoutComponent implements OnInit { ), filter((org) => org != null), ); + this.showSponsoredFamiliesDropdown$ = + this.freeFamiliesPolicyService.showSponsoredFamiliesDropdown$(this.organization$); this.showAccountDeprovisioningBanner$ = combineLatest([ this.bannerService.showBanner$, diff --git a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts index 5220ea1ef39..9666630fc08 100644 --- a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from "@angular/router"; import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { SponsoredFamiliesComponent } from "../../../billing/settings/sponsored-families.component"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { MembersComponent } from "./members.component"; @@ -16,6 +17,14 @@ const routes: Routes = [ titleId: "members", }, }, + { + path: "sponsored-families", + component: SponsoredFamiliesComponent, + canActivate: [organizationPermissionsGuard(canAccessMembersTab)], + data: { + titleId: "sponsoredFamilies", + }, + }, ]; @NgModule({ diff --git a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts new file mode 100644 index 00000000000..10ccc448986 --- /dev/null +++ b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts @@ -0,0 +1,193 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FreeFamiliesPolicyService } from "./free-families-policy.service"; + +describe("FreeFamiliesPolicyService", () => { + let service: FreeFamiliesPolicyService; + let organizationService: MockProxy; + let policyService: MockProxy; + let configService: MockProxy; + let accountService: FakeAccountService; + const userId = Utils.newGuid() as UserId; + + beforeEach(() => { + organizationService = mock(); + policyService = mock(); + configService = mock(); + accountService = mockAccountServiceWith(userId); + + service = new FreeFamiliesPolicyService( + policyService, + organizationService, + accountService, + configService, + ); + }); + + describe("showSponsoredFamiliesDropdown$", () => { + it("should return true when all conditions are met", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization that meets all criteria + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: true, + isOwner: false, + canManageUsers: false, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(true); + }); + + it("should return false when organization is not Enterprise", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization that is not Enterprise tier + const organization = { + id: "org-id", + productTierType: ProductTierType.Teams, + useAdminSponsoredFamilies: true, + isAdmin: true, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(false); + }); + + it("should return false when feature flag is disabled", async () => { + // Configure mocks to disable feature flag + configService.getFeatureFlag$.mockReturnValue(of(false)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization that meets other criteria + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: true, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(false); + }); + + it("should return false when families feature is disabled by policy", async () => { + // Configure mocks with a policy that disables the feature + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue( + of([{ organizationId: "org-id", enabled: true } as Policy]), + ); + + // Create a test organization + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: true, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(false); + }); + + it("should return false when useAdminSponsoredFamilies is false", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization with useAdminSponsoredFamilies set to false + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: false, + isAdmin: true, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(false); + }); + + it("should return true when user is an owner but not admin", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization where user is owner but not admin + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: false, + isOwner: true, + canManageUsers: false, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(true); + }); + + it("should return true when user can manage users but is not admin or owner", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization where user can manage users but is not admin or owner + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: false, + isOwner: false, + canManageUsers: true, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(true); + }); + + it("should return false when user has no admin permissions", async () => { + // Configure mocks + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policiesByType$.mockReturnValue(of([])); + + // Create a test organization where user has no admin permissions + const organization = { + id: "org-id", + productTierType: ProductTierType.Enterprise, + useAdminSponsoredFamilies: true, + isAdmin: false, + isOwner: false, + canManageUsers: false, + } as Organization; + + // Test the method + const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts index 81cb970cdbe..7a8e3804b2c 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.ts @@ -7,6 +7,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; interface EnterpriseOrgStatus { isFreeFamilyPolicyEnabled: boolean; @@ -26,6 +29,7 @@ export class FreeFamiliesPolicyService { private policyService: PolicyService, private organizationService: OrganizationService, private accountService: AccountService, + private configService: ConfigService, ) {} organizations$ = this.accountService.activeAccount$.pipe( @@ -42,6 +46,48 @@ export class FreeFamiliesPolicyService { return this.getFreeFamiliesVisibility$(); } + /** + * Determines whether to show the sponsored families dropdown in the organization layout + * @param organization The organization to check + * @returns Observable indicating whether to show the dropdown + */ + showSponsoredFamiliesDropdown$(organization: Observable): Observable { + const enterpriseOrganization$ = organization.pipe( + map((org) => org.productTierType === ProductTierType.Enterprise), + ); + + return this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => { + const policies$ = this.policyService.policiesByType$( + PolicyType.FreeFamiliesSponsorshipPolicy, + userId, + ); + + return combineLatest([ + enterpriseOrganization$, + this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships), + organization, + policies$, + ]).pipe( + map(([isEnterprise, featureFlagEnabled, org, policies]) => { + const familiesFeatureDisabled = policies.some( + (policy) => policy.organizationId === org.id && policy.enabled, + ); + + return ( + isEnterprise && + featureFlagEnabled && + !familiesFeatureDisabled && + org.useAdminSponsoredFamilies && + (org.isAdmin || org.isOwner || org.canManageUsers) + ); + }), + ); + }), + ); + } + private getFreeFamiliesVisibility$(): Observable { return combineLatest([ this.checkEnterpriseOrganizationsAndFetchPolicy(), diff --git a/libs/common/src/admin-console/models/data/organization.data.spec.ts b/libs/common/src/admin-console/models/data/organization.data.spec.ts index 5f487e1f898..fae24133502 100644 --- a/libs/common/src/admin-console/models/data/organization.data.spec.ts +++ b/libs/common/src/admin-console/models/data/organization.data.spec.ts @@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => { familySponsorshipLastSyncDate: new Date(), userIsManagedByOrganization: false, useRiskInsights: false, + useAdminSponsoredFamilies: false, }, }; const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult))); diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index b81d06e6367..799d062aefa 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -60,6 +60,7 @@ export class OrganizationData { allowAdminAccessToAllCollectionItems: boolean; userIsManagedByOrganization: boolean; useRiskInsights: boolean; + useAdminSponsoredFamilies: boolean; constructor( response?: ProfileOrganizationResponse, @@ -122,6 +123,7 @@ export class OrganizationData { this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems; this.userIsManagedByOrganization = response.userIsManagedByOrganization; this.useRiskInsights = response.useRiskInsights; + this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies; this.isMember = options.isMember; this.isProviderUser = options.isProviderUser; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index c5c5b53cce7..2e51c54b0ad 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -90,6 +90,7 @@ export class Organization { */ userIsManagedByOrganization: boolean; useRiskInsights: boolean; + useAdminSponsoredFamilies: boolean; constructor(obj?: OrganizationData) { if (obj == null) { @@ -148,6 +149,7 @@ export class Organization { this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems; this.userIsManagedByOrganization = obj.userIsManagedByOrganization; this.useRiskInsights = obj.useRiskInsights; + this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies; } get canAccess() { diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 5e37cfc4c5c..da97a1034b1 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse { allowAdminAccessToAllCollectionItems: boolean; userIsManagedByOrganization: boolean; useRiskInsights: boolean; + useAdminSponsoredFamilies: boolean; constructor(response: any) { super(response); @@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse { ); this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization"); this.useRiskInsights = this.getResponseProperty("UseRiskInsights"); + this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies"); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index fa776285ead..9ee1ef919f5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -34,6 +34,7 @@ export enum FeatureFlag { PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", + PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE, + [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, From 6bd3fceaa1eac26fb8116fde0f308bbc40e77922 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:27:48 +0100 Subject: [PATCH 15/36] fix: align upgrade badge with header text in Event Logs (#14213) --- .../organizations/manage/events.component.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.html b/apps/web/src/app/admin-console/organizations/manage/events.component.html index 80d22467123..2079d592a28 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.html @@ -1,6 +1,12 @@ @let usePlaceHolderEvents = !organization?.useEvents && (isBreadcrumbEventLogsEnabled$ | async); - + {{ "upgrade" | i18n }} From 1efdcacd16d5d1fd9e0c853976c73491d40e839d Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Wed, 16 Apr 2025 13:15:43 -0400 Subject: [PATCH 16/36] [PM-16641] Remove "inline-menu-positioning-improvements" feature flag (#14225) * remove inline-menu-positioning-improvements flag * remove unused LegacyOverlayBackground * remove unused deprecated files * appease ts error TS2564 * remove deleted resources from the manifest files --- .../autofill/background/tabs.background.ts | 14 +- .../overlay.background.deprecated.ts | 124 -- .../overlay.background.deprecated.spec.ts | 1464 -------------- .../overlay.background.deprecated.ts | 811 -------- .../abstractions/autofill-init.deprecated.ts | 41 - .../content/autofill-init.deprecated.spec.ts | 604 ------ .../content/autofill-init.deprecated.ts | 315 --- .../bootstrap-legacy-autofill-overlay.ts | 14 - .../autofill-overlay-button.deprecated.ts | 29 - ...ofill-overlay-iframe.service.deprecated.ts | 33 - .../autofill-overlay-list.deprecated.ts | 31 - ...utofill-overlay-page-element.deprecated.ts | 13 - ...lay-iframe.service.deprecated.spec.ts.snap | 23 - ...l-overlay-button-iframe.deprecated.spec.ts | 26 - ...tofill-overlay-button-iframe.deprecated.ts | 21 - ...-overlay-iframe-element.deprecated.spec.ts | 46 - ...ofill-overlay-iframe-element.deprecated.ts | 22 - ...-overlay-iframe.service.deprecated.spec.ts | 521 ----- ...ofill-overlay-iframe.service.deprecated.ts | 429 ---- ...ill-overlay-list-iframe.deprecated.spec.ts | 26 - ...autofill-overlay-list-iframe.deprecated.ts | 26 - ...ill-overlay-button.deprecated.spec.ts.snap | 83 - ...autofill-overlay-button.deprecated.spec.ts | 135 -- .../autofill-overlay-button.deprecated.ts | 124 -- ...trap-autofill-overlay-button.deprecated.ts | 11 - .../overlay/pages/button/legacy-button.html | 12 - .../overlay/pages/button/legacy-button.scss | 36 - ...ofill-overlay-list.deprecated.spec.ts.snap | 537 ----- .../autofill-overlay-list.deprecated.spec.ts | 467 ----- .../list/autofill-overlay-list.deprecated.ts | 621 ------ ...tstrap-autofill-overlay-list.deprecated.ts | 11 - .../overlay/pages/list/legacy-list.html | 12 - .../overlay/pages/list/legacy-list.scss | 292 --- ...ll-overlay-page-element.deprecated.spec.ts | 222 --- ...utofill-overlay-page-element.deprecated.ts | 157 -- .../autofill-overlay-content.service.ts | 37 - ...overlay-content.service.deprecated.spec.ts | 1743 ----------------- ...fill-overlay-content.service.deprecated.ts | 1139 ----------- .../popup/settings/autofill.component.html | 10 +- .../popup/settings/autofill.component.ts | 17 +- .../src/autofill/services/autofill.service.ts | 9 - .../browser/src/background/main.background.ts | 56 +- apps/browser/src/manifest.json | 9 +- apps/browser/src/manifest.v3.json | 9 +- apps/browser/webpack.config.js | 16 - libs/common/src/enums/feature-flag.enum.ts | 2 - 46 files changed, 29 insertions(+), 10371 deletions(-) delete mode 100644 apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-button-iframe.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-button-iframe.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-iframe-element.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-iframe-element.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-iframe.service.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-iframe.service.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-list-iframe.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/iframe-content/autofill-overlay-list-iframe.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/__snapshots__/autofill-overlay-button.deprecated.spec.ts.snap delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/autofill-overlay-button.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/autofill-overlay-button.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/bootstrap-autofill-overlay-button.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/legacy-button.html delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/button/legacy-button.scss delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/__snapshots__/autofill-overlay-list.deprecated.spec.ts.snap delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/autofill-overlay-list.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/autofill-overlay-list.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/legacy-list.html delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/list/legacy-list.scss delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/shared/autofill-overlay-page-element.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/overlay/pages/shared/autofill-overlay-page-element.deprecated.ts delete mode 100644 apps/browser/src/autofill/deprecated/services/abstractions/autofill-overlay-content.service.ts delete mode 100644 apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.spec.ts delete mode 100644 apps/browser/src/autofill/deprecated/services/autofill-overlay-content.service.deprecated.ts diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index b07e06234d3..c093f1a3b00 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -1,7 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; - import MainBackground from "../../background/main.background"; import { OverlayBackground } from "./abstractions/overlay.background"; @@ -14,7 +10,7 @@ export default class TabsBackground { private overlayBackground: OverlayBackground, ) {} - private focusedWindowId: number; + private focusedWindowId: number = -1; /** * Initializes the window and tab listeners. @@ -90,14 +86,6 @@ export default class TabsBackground { changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab, ) => { - const overlayImprovementsFlag = await this.main.configService.getFeatureFlag( - FeatureFlag.InlineMenuPositioningImprovements, - ); - const removePageDetailsStatus = new Set(["loading", "unloaded"]); - if (!overlayImprovementsFlag && removePageDetailsStatus.has(changeInfo.status)) { - this.overlayBackground.removePageDetails(tabId); - } - if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) { return; } diff --git a/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts deleted file mode 100644 index 88b78dc2495..00000000000 --- a/apps/browser/src/autofill/deprecated/background/abstractions/overlay.background.deprecated.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; - -import { LockedVaultPendingNotificationsData } from "../../../background/abstractions/notification.background"; -import AutofillPageDetails from "../../../models/autofill-page-details"; - -type WebsiteIconData = { - imageEnabled: boolean; - image: string; - fallbackImage: string; - icon: string; -}; - -type OverlayAddNewItemMessage = { - login?: { - uri?: string; - hostname: string; - username: string; - password: string; - }; -}; - -type OverlayBackgroundExtensionMessage = { - [key: string]: any; - command: string; - tab?: chrome.tabs.Tab; - sender?: string; - details?: AutofillPageDetails; - overlayElement?: string; - display?: string; - data?: LockedVaultPendingNotificationsData; -} & OverlayAddNewItemMessage; - -type OverlayPortMessage = { - [key: string]: any; - command: string; - direction?: string; - overlayCipherId?: string; -}; - -type FocusedFieldData = { - focusedFieldStyles: Partial; - focusedFieldRects: Partial; - tabId?: number; -}; - -type OverlayCipherData = { - id: string; - name: string; - type: CipherType; - reprompt: CipherRepromptType; - favorite: boolean; - icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string }; - login?: { username: string }; - card?: string; -}; - -type BackgroundMessageParam = { - message: OverlayBackgroundExtensionMessage; -}; -type BackgroundSenderParam = { - sender: chrome.runtime.MessageSender; -}; -type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; - -type OverlayBackgroundExtensionMessageHandlers = { - [key: string]: CallableFunction; - openAutofillOverlay: () => void; - autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - getAutofillOverlayVisibility: () => void; - checkAutofillOverlayFocused: () => void; - focusAutofillOverlayList: () => void; - updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; - updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - unlockCompleted: ({ message }: BackgroundMessageParam) => void; - addedCipher: () => void; - addEditCipherSubmitted: () => void; - editedCipher: () => void; - deletedCipher: () => void; -}; - -type PortMessageParam = { - message: OverlayPortMessage; -}; -type PortConnectionParam = { - port: chrome.runtime.Port; -}; -type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; - -type OverlayButtonPortMessageHandlers = { - [key: string]: CallableFunction; - overlayButtonClicked: ({ port }: PortConnectionParam) => void; - closeAutofillOverlay: ({ port }: PortConnectionParam) => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; -}; - -type OverlayListPortMessageHandlers = { - [key: string]: CallableFunction; - checkAutofillOverlayButtonFocused: () => void; - forceCloseAutofillOverlay: ({ port }: PortConnectionParam) => void; - overlayPageBlurred: () => void; - unlockVault: ({ port }: PortConnectionParam) => void; - fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void; - addNewVaultItem: ({ port }: PortConnectionParam) => void; - viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; - redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; -}; - -export { - WebsiteIconData, - OverlayBackgroundExtensionMessage, - OverlayPortMessage, - FocusedFieldData, - OverlayCipherData, - OverlayAddNewItemMessage, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayListPortMessageHandlers, -}; diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts deleted file mode 100644 index 68f8032350e..00000000000 --- a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts +++ /dev/null @@ -1,1464 +0,0 @@ -import { mock, MockProxy, mockReset } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; - -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AuthService } from "@bitwarden/common/auth/services/auth.service"; -import { - SHOW_AUTOFILL_BUTTON, - AutofillOverlayVisibility, -} from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { - DefaultDomainSettingsService, - DomainSettingsService, -} from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { - EnvironmentService, - Region, -} from "@bitwarden/common/platform/abstractions/environment.service"; -import { ThemeType } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CloudEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; -import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -import { - FakeStateProvider, - FakeAccountService, - mockAccountServiceWith, -} from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; - -import { BrowserApi } from "../../../platform/browser/browser-api"; -import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; -import { - AutofillOverlayElement, - AutofillOverlayPort, - RedirectFocusDirection, -} from "../../enums/autofill-overlay.enum"; -import { AutofillService } from "../../services/abstractions/autofill.service"; -import { - createAutofillPageDetailsMock, - createChromeTabMock, - createFocusedFieldDataMock, - createPageDetailMock, - createPortSpyMock, -} from "../../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage, sendPortMessage } from "../../spec/testing-utils"; - -import LegacyOverlayBackground from "./overlay.background.deprecated"; - -describe("OverlayBackground", () => { - const mockUserId = Utils.newGuid() as UserId; - const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); - const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); - let domainSettingsService: DomainSettingsService; - let buttonPortSpy: chrome.runtime.Port; - let listPortSpy: chrome.runtime.Port; - let overlayBackground: LegacyOverlayBackground; - const cipherService = mock(); - const autofillService = mock(); - let configService: MockProxy; - let activeAccountStatusMock$: BehaviorSubject; - let authService: MockProxy; - - const environmentService = mock(); - environmentService.environment$ = new BehaviorSubject( - new CloudEnvironment({ - key: Region.US, - domain: "bitwarden.com", - urls: { icons: "https://icons.bitwarden.com/" }, - }), - ); - const autofillSettingsService = mock(); - const i18nService = mock(); - const platformUtilsService = mock(); - const themeStateService = mock(); - const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { - const { initList, initButton } = options; - if (initButton) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); - buttonPortSpy = overlayBackground["overlayButtonPort"]; - } - - if (initList) { - await overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); - listPortSpy = overlayBackground["overlayListPort"]; - } - - return { buttonPortSpy, listPortSpy }; - }; - - beforeEach(() => { - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(true)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); - activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); - authService = mock(); - authService.activeAccountStatus$ = activeAccountStatusMock$; - overlayBackground = new LegacyOverlayBackground( - cipherService, - autofillService, - authService, - environmentService, - domainSettingsService, - autofillSettingsService, - i18nService, - platformUtilsService, - themeStateService, - accountService, - ); - - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); - - themeStateService.selectedTheme$ = of(ThemeType.Light); - domainSettingsService.showFavicons$ = of(true); - - void overlayBackground.init(); - }); - - afterEach(() => { - jest.clearAllMocks(); - mockReset(cipherService); - }); - - describe("removePageDetails", () => { - it("removes the page details for a specific tab from the pageDetailsForTab object", () => { - const tabId = 1; - const frameId = 2; - overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); - overlayBackground.removePageDetails(tabId); - - expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); - }); - }); - - describe("init", () => { - it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { - overlayBackground["setupExtensionMessageListeners"] = jest.fn(); - overlayBackground["getOverlayVisibility"] = jest.fn(); - overlayBackground["getAuthStatus"] = jest.fn(); - - await overlayBackground.init(); - - expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - }); - }); - - describe("updateOverlayCiphers", () => { - const url = "https://jest-testing-website.com"; - const tab = createChromeTabMock({ url }); - const cipher1 = mock({ - id: "id-1", - localData: { lastUsedDate: 222 }, - name: "name-1", - type: CipherType.Login, - login: { username: "username-1", uri: url }, - }); - const cipher2 = mock({ - id: "id-2", - localData: { lastUsedDate: 111 }, - name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, - }); - - beforeEach(() => { - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - }); - - it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { - activeAccountStatusMock$.next(AuthenticationStatus.Locked); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); - - await overlayBackground.updateOverlayCiphers(); - - expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); - }); - - it("ignores updating the overlay ciphers if the tab is undefined", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); - jest.spyOn(cipherService, "getAllDecryptedForUrl"); - - await overlayBackground.updateOverlayCiphers(); - - expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); - }); - - it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); - cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData"); - - await overlayBackground.updateOverlayCiphers(); - - expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, mockUserId); - expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); - expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( - new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], - ]), - ); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); - }); - - it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { - overlayBackground["overlayListPort"] = mock(); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); - cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - await overlayBackground.updateOverlayCiphers(); - - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayListCiphers", - ciphers: [ - { - card: null, - favorite: cipher2.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, - name: "name-2", - reprompt: cipher2.reprompt, - type: 1, - }, - { - card: null, - favorite: cipher1.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-1", - login: { - username: "username-1", - }, - name: "name-1", - reprompt: cipher1.reprompt, - type: 1, - }, - ], - }); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - tab, - "updateIsOverlayCiphersPopulated", - { isOverlayCiphersPopulated: true }, - ); - }); - }); - - describe("getOverlayCipherData", () => { - const url = "https://jest-testing-website.com"; - const cipher1 = mock({ - id: "id-1", - localData: { lastUsedDate: 222 }, - name: "name-1", - type: CipherType.Login, - login: { username: "username-1", uri: url }, - }); - const cipher2 = mock({ - id: "id-2", - localData: { lastUsedDate: 111 }, - name: "name-2", - type: CipherType.Login, - login: { username: "username-2", uri: url }, - }); - const cipher3 = mock({ - id: "id-3", - localData: { lastUsedDate: 333 }, - name: "name-3", - type: CipherType.Card, - card: { subTitle: "Visa, *6789" }, - }); - const cipher4 = mock({ - id: "id-4", - localData: { lastUsedDate: 444 }, - name: "name-4", - type: CipherType.Card, - card: { subTitle: "Mastercard, *1234" }, - }); - - it("formats and returns the cipher data", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher3], - ["overlay-cipher-3", cipher4], - ]); - - const overlayCipherData = await overlayBackground["getOverlayCipherData"](); - - expect(overlayCipherData).toStrictEqual([ - { - card: null, - favorite: cipher2.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-0", - login: { - username: "username-2", - }, - name: "name-2", - reprompt: cipher2.reprompt, - type: 1, - }, - { - card: null, - favorite: cipher1.favorite, - icon: { - fallbackImage: "images/bwi-globe.png", - icon: "bwi-globe", - image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", - imageEnabled: true, - }, - id: "overlay-cipher-1", - login: { - username: "username-1", - }, - name: "name-1", - reprompt: cipher1.reprompt, - type: 1, - }, - { - card: "Visa, *6789", - favorite: cipher3.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: null, - imageEnabled: true, - }, - id: "overlay-cipher-2", - login: null, - name: "name-3", - reprompt: cipher3.reprompt, - type: 3, - }, - { - card: "Mastercard, *1234", - favorite: cipher4.favorite, - icon: { - fallbackImage: "", - icon: "bwi-credit-card", - image: null, - imageEnabled: true, - }, - id: "overlay-cipher-3", - login: null, - name: "name-4", - reprompt: cipher4.reprompt, - type: 3, - }, - ]); - }); - }); - - describe("getAuthStatus", () => { - it("will update the user's auth status but will not update the overlay ciphers", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); - - const status = await overlayBackground["getAuthStatus"](); - - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - expect(status).toBe(authStatus); - }); - - it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { - const authStatus = AuthenticationStatus.Unlocked; - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); - jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); - - await overlayBackground["getAuthStatus"](); - - expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); - expect(overlayBackground["userAuthStatus"]).toBe(authStatus); - }); - }); - - describe("updateOverlayButtonAuthStatus", () => { - it("will send a message to the button port with the user's auth status", () => { - overlayBackground["overlayButtonPort"] = mock(); - jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); - - overlayBackground["updateOverlayButtonAuthStatus"](); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayButtonAuthStatus", - authStatus: overlayBackground["userAuthStatus"], - }); - }); - }); - - describe("getTranslations", () => { - it("will query the overlay page translations if they have not been queried", () => { - overlayBackground["overlayPageTranslations"] = undefined; - jest.spyOn(overlayBackground as any, "getTranslations"); - jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); - jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); - - const translations = overlayBackground["getTranslations"](); - - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - const translationKeys = [ - "opensInANewWindow", - "bitwardenOverlayButton", - "toggleBitwardenVaultOverlay", - "bitwardenVault", - "unlockYourAccountToViewMatchingLogins", - "unlockAccount", - "fillCredentialsFor", - "partialUsername", - "view", - "noItemsToShow", - "newItem", - "addNewVaultItem", - ]; - translationKeys.forEach((key) => { - expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); - }); - expect(translations).toStrictEqual({ - locale: "en", - opensInANewWindow: "opensInANewWindow", - buttonPageTitle: "bitwardenOverlayButton", - toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", - listPageTitle: "bitwardenVault", - unlockYourAccount: "unlockYourAccountToViewMatchingLogins", - unlockAccount: "unlockAccount", - fillCredentialsFor: "fillCredentialsFor", - partialUsername: "partialUsername", - view: "view", - noItemsToShow: "noItemsToShow", - newItem: "newItem", - addNewVaultItem: "addNewVaultItem", - }); - }); - }); - - describe("setupExtensionMessageListeners", () => { - it("will set up onMessage and onConnect listeners", () => { - overlayBackground["setupExtensionMessageListeners"](); - - expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); - expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); - }); - }); - - describe("handleExtensionMessage", () => { - it("will return early if the message command is not present within the extensionMessageHandlers", () => { - const message = { - command: "not-a-command", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); - - expect(returnValue).toBe(null); - expect(sendResponse).not.toHaveBeenCalled(); - }); - - it("will trigger the message handler and return undefined if the message does not have a response", () => { - const message = { - command: "autofillOverlayElementClosed", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "overlayElementClosed"); - - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); - - expect(returnValue).toBe(null); - expect(sendResponse).not.toHaveBeenCalled(); - expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender); - }); - - it("will return a response if the message handler returns a response", async () => { - const message = { - command: "openAutofillOverlay", - }; - const sender = mock({ tab: { id: 1 } }); - const sendResponse = jest.fn(); - jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); - - const returnValue = overlayBackground["handleExtensionMessage"]( - message, - sender, - sendResponse, - ); - - expect(returnValue).toBe(true); - }); - - describe("extension message handlers", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockResolvedValue(AuthenticationStatus.Unlocked); - }); - - describe("openAutofillOverlay message handler", () => { - it("opens the autofill overlay by sending a message to the current tab", async () => { - const sender = mock({ tab: { id: 1 } }); - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendMockExtensionMessage({ command: "openAutofillOverlay" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: false, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); - }); - }); - - describe("autofillOverlayElementClosed message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - }); - - it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => { - const port1 = mock(); - const port2 = mock(); - overlayBackground["expiredPorts"] = [port1, port2]; - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage( - { - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }, - sender, - ); - - expect(port1.disconnect).toHaveBeenCalled(); - expect(port2.disconnect).toHaveBeenCalled(); - }); - - it("disconnects the button element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayButtonPort"]).toBeNull(); - }); - - it("disconnects the list element port", () => { - sendMockExtensionMessage({ - command: "autofillOverlayElementClosed", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.disconnect).toHaveBeenCalled(); - expect(overlayBackground["overlayListPort"]).toBeNull(); - }); - }); - - describe("autofillOverlayAddNewVaultItem message handler", () => { - let sender: chrome.runtime.MessageSender; - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - jest - .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") - .mockImplementation(); - jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); - }); - - it("will not open the add edit popout window if the message does not have a login cipher provided", () => { - sendMockExtensionMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); - expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); - }); - - it("will open the add edit popout window after creating a new cipher", async () => { - jest.spyOn(BrowserApi, "sendMessage"); - - sendMockExtensionMessage( - { - command: "autofillOverlayAddNewVaultItem", - login: { - uri: "https://tacos.com", - hostname: "", - username: "username", - password: "password", - }, - }, - sender, - ); - await flushPromises(); - - expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); - expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); - }); - }); - - describe("getAutofillOverlayVisibility message handler", () => { - beforeEach(() => { - jest - .spyOn(overlayBackground as any, "getOverlayVisibility") - .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); - }); - - it("will set the overlayVisibility property", async () => { - sendMockExtensionMessage({ command: "getAutofillOverlayVisibility" }); - await flushPromises(); - - expect(await overlayBackground["getOverlayVisibility"]()).toBe( - AutofillOverlayVisibility.OnFieldFocus, - ); - }); - - it("returns the overlayVisibility property", async () => { - const sendMessageSpy = jest.fn(); - - sendMockExtensionMessage( - { command: "getAutofillOverlayVisibility" }, - undefined, - sendMessageSpy, - ); - await flushPromises(); - - expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); - }); - }); - - describe("checkAutofillOverlayFocused message handler", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - }); - - it("will check if the overlay list is focused if the list port is open", () => { - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - }); - - it("will check if the overlay button is focused if the list port is not open", () => { - overlayBackground["overlayListPort"] = undefined; - - sendMockExtensionMessage({ command: "checkAutofillOverlayFocused" }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "checkAutofillOverlayButtonFocused", - }); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "checkAutofillOverlayListFocused", - }); - }); - }); - - describe("focusAutofillOverlayList message handler", () => { - it("will send a `focusOverlayList` message to the overlay list port", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); - - sendMockExtensionMessage({ command: "focusAutofillOverlayList" }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); - }); - }); - - describe("updateAutofillOverlayPosition message handler", () => { - beforeEach(async () => { - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.List), - ); - listPortSpy = overlayBackground["overlayListPort"]; - - await overlayBackground["handlePortOnConnect"]( - createPortSpyMock(AutofillOverlayPort.Button), - ); - buttonPortSpy = overlayBackground["overlayButtonPort"]; - }); - - it("ignores updating the position if the overlay element type is not provided", () => { - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("skips updating the position if the most recently focused field is different than the message sender", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ command: "updateAutofillOverlayPosition" }, sender); - - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: expect.anything(), - }); - }); - - it("updates the overlay button's position", () => { - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, - }); - }); - - it("modifies the overlay button's height for medium sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, - }); - }); - - it("modifies the overlay button's height for large sized input elements", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, - }); - }); - - it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { - const focusedFieldData = createFocusedFieldDataMock({ - focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, - }); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.Button, - }); - - expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, - }); - }); - - it("will post a message to the overlay list facilitating an update of the list's position", () => { - const sender = mock({ tab: { id: 1 } }); - const focusedFieldData = createFocusedFieldDataMock(); - sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }); - - overlayBackground["updateOverlayPosition"]( - { overlayElement: AutofillOverlayElement.List }, - sender, - ); - sendMockExtensionMessage({ - command: "updateAutofillOverlayPosition", - overlayElement: AutofillOverlayElement.List, - }); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith({ - command: "updateIframePosition", - styles: { left: "2px", top: "4px", width: "4px" }, - }); - }); - }); - - describe("updateOverlayHidden", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - }); - - it("returns early if the display value is not provided", () => { - const message = { - command: "updateAutofillOverlayHidden", - }; - - sendMockExtensionMessage(message); - - expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); - expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); - }); - - it("posts a message to the overlay button and list with the display value", () => { - const message = { command: "updateAutofillOverlayHidden", display: "none" }; - - sendMockExtensionMessage(message); - - expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ - command: "updateOverlayHidden", - styles: { - display: message.display, - }, - }); - }); - }); - - describe("collectPageDetailsResponse message handler", () => { - let sender: chrome.runtime.MessageSender; - const pageDetails1 = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - const pageDetails2 = createAutofillPageDetailsMock({ - login: { username: "username2", password: "password2" }, - }); - - beforeEach(() => { - sender = mock({ tab: { id: 1 } }); - }); - - it("stores the page details provided by the message by the tab id of the sender", () => { - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails1 }, - sender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]), - ); - }); - - it("updates the page details for a tab that already has a set of page details stored ", () => { - const secondFrameSender = mock({ - tab: { id: 1 }, - frameId: 3, - }); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - ]); - - sendMockExtensionMessage( - { command: "collectPageDetailsResponse", details: pageDetails2 }, - secondFrameSender, - ); - - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( - new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], - [ - secondFrameSender.frameId, - { - frameId: secondFrameSender.frameId, - tab: secondFrameSender.tab, - details: pageDetails2, - }, - ], - ]), - ); - }); - }); - - describe("unlockCompleted message handler", () => { - let getAuthStatusSpy: jest.SpyInstance; - - beforeEach(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(BrowserApi, "tabSendMessageData"); - getAuthStatusSpy = jest - .spyOn(overlayBackground as any, "getAuthStatus") - .mockImplementation(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - return Promise.resolve(AuthenticationStatus.Unlocked); - }); - }); - - it("updates the user's auth status but does not open the overlay", async () => { - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "" } }, - }, - }; - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { - const sender = mock({ tab: { id: 1 } }); - const message = { - command: "unlockCompleted", - data: { - commandToRetry: { message: { command: "openAutofillOverlay" } }, - }, - }; - jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(getAuthStatusSpy).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - sender.tab, - "openAutofillOverlay", - { - isFocusingFieldElement: true, - isOpeningFullOverlay: false, - authStatus: AuthenticationStatus.Unlocked, - }, - ); - }); - }); - - describe("extension messages that trigger an update of the inline menu ciphers", () => { - const extensionMessages = [ - "addedCipher", - "addEditCipherSubmitted", - "editedCipher", - "deletedCipher", - ]; - - beforeEach(() => { - jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); - }); - - extensionMessages.forEach((message) => { - it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { - sendMockExtensionMessage({ command: message }); - expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); - }); - }); - }); - }); - }); - - describe("handlePortOnConnect", () => { - beforeEach(() => { - jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); - jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); - jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); - jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); - }); - - it("skips setting up the overlay port if the port connection is not for an overlay element", async () => { - const port = createPortSpyMock("not-an-overlay-element"); - - await overlayBackground["handlePortOnConnect"](port); - - expect(port.onMessage.addListener).not.toHaveBeenCalled(); - expect(port.postMessage).not.toHaveBeenCalled(); - }); - - it("sets up the overlay list port if the port connection is for the overlay list", async () => { - await initOverlayElementPorts({ initList: true, initButton: false }); - await flushPromises(); - - expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); - expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(listPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.List }, - listPortSpy.sender, - ); - }); - - it("sets up the overlay button port if the port connection is for the overlay button", async () => { - await initOverlayElementPorts({ initList: false, initButton: true }); - await flushPromises(); - - expect(overlayBackground["overlayListPort"]).toBeUndefined(); - expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); - expect(buttonPortSpy.postMessage).toHaveBeenCalled(); - expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); - expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); - expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); - expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith( - { overlayElement: AutofillOverlayElement.Button }, - buttonPortSpy.sender, - ); - }); - - it("stores an existing overlay port so that it can be disconnected at a later time", async () => { - overlayBackground["overlayButtonPort"] = mock(); - - await initOverlayElementPorts({ initList: false, initButton: true }); - await flushPromises(); - - expect(overlayBackground["expiredPorts"].length).toBe(1); - }); - - it("gets the system theme", async () => { - themeStateService.selectedTheme$ = of(ThemeType.System); - - await initOverlayElementPorts({ initList: true, initButton: false }); - await flushPromises(); - - expect(listPortSpy.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ theme: ThemeType.System }), - ); - }); - }); - - describe("handleOverlayElementPortMessage", () => { - beforeEach(async () => { - await initOverlayElementPorts(); - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; - }); - - it("ignores port messages that do not contain a handler", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); - }); - - describe("overlay button message handlers", () => { - it("unlocks the vault if the user auth status is not unlocked", () => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; - jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); - }); - - it("opens the autofill overlay if the auth status is unlocked", () => { - jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); - - sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); - - expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); - }); - - describe("closeAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: false }, - ); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(buttonPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks if the overlay list is focused", () => { - jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); - - sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); - }); - }); - - describe("redirectOverlayFocusOut", () => { - beforeEach(() => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - }); - - it("ignores the redirect message if the direction is not provided", () => { - sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); - - expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); - }); - - it("sends the redirect message if the direction is provided", () => { - sendPortMessage(buttonPortSpy, { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - buttonPortSpy.sender.tab, - "redirectOverlayFocusOut", - { direction: RedirectFocusDirection.Next }, - ); - }); - }); - }); - - describe("overlay list message handlers", () => { - describe("checkAutofillOverlayButtonFocused", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("forceCloseAutofillOverlay", () => { - it("sends a `closeOverlay` message to the sender tab with a `forceCloseOverlay` flag of `true` set", () => { - jest.spyOn(BrowserApi, "tabSendMessageData"); - - sendPortMessage(listPortSpy, { command: "forceCloseAutofillOverlay" }); - - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "closeAutofillOverlay", - { forceCloseOverlay: true }, - ); - }); - }); - - describe("overlayPageBlurred", () => { - it("checks on the focus state of the overlay button", () => { - jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); - - expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); - }); - }); - - describe("unlockVault", () => { - it("closes the autofill overlay and opens the unlock popout", async () => { - jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); - jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); - - sendPortMessage(listPortSpy, { command: "unlockVault" }); - await flushPromises(); - - expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( - listPortSpy.sender.tab, - "addToLockedVaultPendingNotifications", - { - commandToRetry: { - message: { command: "openAutofillOverlay" }, - sender: listPortSpy.sender, - }, - target: "overlay.background", - }, - ); - expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - true, - ); - }); - }); - - describe("fillSelectedListItem", () => { - let getLoginCiphersSpy: jest.SpyInstance; - let isPasswordRepromptRequiredSpy: jest.SpyInstance; - let doAutoFillSpy: jest.SpyInstance; - let sender: chrome.runtime.MessageSender; - const pageDetails = createAutofillPageDetailsMock({ - login: { username: "username1", password: "password1" }, - }); - - beforeEach(() => { - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy = jest.spyOn( - overlayBackground["autofillService"], - "isPasswordRepromptRequired", - ); - doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); - sender = mock({ tab: { id: 1 } }); - }); - - it("ignores the fill request if the overlay cipher id is not provided", async () => { - sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if the tab does not contain any identified page details", async () => { - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).not.toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("ignores the fill request if a master password reprompt is required", async () => { - const cipher = mock({ - reprompt: CipherRepromptType.Password, - type: CipherType.Login, - }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); - isPasswordRepromptRequiredSpy.mockResolvedValue(true); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(getLoginCiphersSpy).toHaveBeenCalled(); - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).not.toHaveBeenCalled(); - }); - - it("autofills the selected cipher and move it to the top of the front of the ciphers map", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - const cipher2 = mock({ id: "overlay-cipher-2" }); - const cipher3 = mock({ id: "overlay-cipher-3" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-1", cipher1], - ["overlay-cipher-2", cipher2], - ["overlay-cipher-3", cipher3], - ]); - const pageDetailsForTab = { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails, - }; - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, pageDetailsForTab], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( - cipher2, - listPortSpy.sender.tab, - ); - expect(doAutoFillSpy).toHaveBeenCalledWith({ - tab: listPortSpy.sender.tab, - cipher: cipher2, - pageDetails: [pageDetailsForTab], - fillNewPassword: true, - allowTotpAutofill: true, - }); - expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( - new Map([ - ["overlay-cipher-2", cipher2], - ["overlay-cipher-1", cipher1], - ["overlay-cipher-3", cipher3], - ]).entries(), - ); - }); - - it("copies the cipher's totp code to the clipboard after filling", async () => { - const cipher1 = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], - ]); - isPasswordRepromptRequiredSpy.mockResolvedValue(false); - const copyToClipboardSpy = jest - .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") - .mockImplementation(); - doAutoFillSpy.mockReturnValueOnce("totp-code"); - - sendPortMessage(listPortSpy, { - command: "fillSelectedListItem", - overlayCipherId: "overlay-cipher-2", - }); - await flushPromises(); - - expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); - }); - }); - - describe("getNewVaultItemDetails", () => { - it("will send an addNewVaultItemFromOverlay message", async () => { - jest.spyOn(BrowserApi, "tabSendMessage"); - - sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); - await flushPromises(); - - expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { - command: "addNewVaultItemFromOverlay", - }); - }); - }); - - describe("viewSelectedCipher", () => { - let openViewVaultItemPopoutSpy: jest.SpyInstance; - - beforeEach(() => { - openViewVaultItemPopoutSpy = jest - .spyOn(overlayBackground as any, "openViewVaultItemPopout") - .mockImplementation(); - }); - - it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); - }); - - it("will open the view vault item popout with the selected cipher", async () => { - const cipher = mock({ id: "overlay-cipher-1" }); - overlayBackground["overlayLoginCiphers"] = new Map([ - ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], - ["overlay-cipher-1", cipher], - ]); - - sendPortMessage(listPortSpy, { - command: "viewSelectedCipher", - overlayCipherId: "overlay-cipher-1", - }); - await flushPromises(); - - expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( - listPortSpy.sender.tab, - { - cipherId: cipher.id, - action: SHOW_AUTOFILL_BUTTON, - }, - ); - }); - }); - - describe("redirectOverlayFocusOut", () => { - it("redirects focus out of the overlay list", async () => { - const message = { - command: "redirectOverlayFocusOut", - direction: RedirectFocusDirection.Next, - }; - const redirectOverlayFocusOutSpy = jest.spyOn( - overlayBackground as any, - "redirectOverlayFocusOut", - ); - - sendPortMessage(listPortSpy, message); - await flushPromises(); - - expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); - }); - }); - }); - }); -}); diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts deleted file mode 100644 index c9eb442d75d..00000000000 --- a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.ts +++ /dev/null @@ -1,811 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; -import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; -import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; - -import { openUnlockPopout } from "../../../auth/popup/utils/auth-popout-window"; -import { BrowserApi } from "../../../platform/browser/browser-api"; -import { - openViewVaultItemPopout, - openAddEditVaultItemPopout, -} from "../../../vault/popup/utils/vault-popout-window"; -import { LockedVaultPendingNotificationsData } from "../../background/abstractions/notification.background"; -import { OverlayBackground as OverlayBackgroundInterface } from "../../background/abstractions/overlay.background"; -import { AutofillOverlayElement, AutofillOverlayPort } from "../../enums/autofill-overlay.enum"; -import { AutofillService, PageDetail } from "../../services/abstractions/autofill.service"; - -import { - FocusedFieldData, - OverlayBackgroundExtensionMessageHandlers, - OverlayButtonPortMessageHandlers, - OverlayCipherData, - OverlayListPortMessageHandlers, - OverlayBackgroundExtensionMessage, - OverlayAddNewItemMessage, - OverlayPortMessage, - WebsiteIconData, -} from "./abstractions/overlay.background.deprecated"; - -class LegacyOverlayBackground implements OverlayBackgroundInterface { - private readonly openUnlockPopout = openUnlockPopout; - private readonly openViewVaultItemPopout = openViewVaultItemPopout; - private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; - private overlayLoginCiphers: Map = new Map(); - private pageDetailsForTab: Record< - chrome.runtime.MessageSender["tab"]["id"], - Map - > = {}; - private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; - private overlayButtonPort: chrome.runtime.Port; - private overlayListPort: chrome.runtime.Port; - private expiredPorts: chrome.runtime.Port[] = []; - private focusedFieldData: FocusedFieldData; - private overlayPageTranslations: Record; - private iconsServerUrl: string; - private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { - openAutofillOverlay: () => this.openOverlay(false), - autofillOverlayElementClosed: ({ message, sender }) => - this.overlayElementClosed(message, sender), - autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), - getAutofillOverlayVisibility: () => this.getOverlayVisibility(), - checkAutofillOverlayFocused: () => this.checkOverlayFocused(), - focusAutofillOverlayList: () => this.focusOverlayList(), - updateAutofillOverlayPosition: ({ message, sender }) => - this.updateOverlayPosition(message, sender), - updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), - updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), - collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), - unlockCompleted: ({ message }) => this.unlockCompleted(message), - addedCipher: () => this.updateOverlayCiphers(), - addEditCipherSubmitted: () => this.updateOverlayCiphers(), - editedCipher: () => this.updateOverlayCiphers(), - deletedCipher: () => this.updateOverlayCiphers(), - }; - private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { - overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), - closeAutofillOverlay: ({ port }) => this.closeOverlay(port), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayListFocused(), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), - }; - private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { - checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), - forceCloseAutofillOverlay: ({ port }) => this.closeOverlay(port, true), - overlayPageBlurred: () => this.checkOverlayButtonFocused(), - unlockVault: ({ port }) => this.unlockVault(port), - fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port), - addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), - viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), - redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), - }; - - constructor( - private cipherService: CipherService, - private autofillService: AutofillService, - private authService: AuthService, - private environmentService: EnvironmentService, - private domainSettingsService: DomainSettingsService, - private autofillSettingsService: AutofillSettingsServiceAbstraction, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private themeStateService: ThemeStateService, - private accountService: AccountService, - ) {} - - /** - * Removes cached page details for a tab - * based on the passed tabId. - * - * @param tabId - Used to reference the page details of a specific tab - */ - removePageDetails(tabId: number) { - if (!this.pageDetailsForTab[tabId]) { - return; - } - - this.pageDetailsForTab[tabId].clear(); - delete this.pageDetailsForTab[tabId]; - } - - /** - * Sets up the extension message listeners and gets the settings for the - * overlay's visibility and the user's authentication status. - */ - async init() { - this.setupExtensionMessageListeners(); - const env = await firstValueFrom(this.environmentService.environment$); - this.iconsServerUrl = env.getIconsUrl(); - await this.getOverlayVisibility(); - await this.getAuthStatus(); - } - - /** - * Updates the overlay list's ciphers and sends the updated list to the overlay list iframe. - * Queries all ciphers for the given url, and sorts them by last used. Will not update the - * list of ciphers if the extension is not unlocked. - */ - async updateOverlayCiphers() { - const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); - if (authStatus !== AuthenticationStatus.Unlocked) { - return; - } - - const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - if (!currentTab?.url) { - return; - } - - this.overlayLoginCiphers = new Map(); - - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - const ciphersViews = ( - await this.cipherService.getAllDecryptedForUrl(currentTab.url, activeUserId) - ).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); - for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { - this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); - } - - const ciphers = await this.getOverlayCipherData(); - this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); - await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { - isOverlayCiphersPopulated: Boolean(ciphers.length), - }); - } - - /** - * Strips out unnecessary data from the ciphers and returns an array of - * objects that contain the cipher data needed for the overlay list. - */ - private async getOverlayCipherData(): Promise { - const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); - const overlayCiphersArray = Array.from(this.overlayLoginCiphers); - const overlayCipherData: OverlayCipherData[] = []; - let loginCipherIcon: WebsiteIconData; - - for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { - const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; - if (!loginCipherIcon && cipher.type === CipherType.Login) { - loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, showFavicons); - } - - overlayCipherData.push({ - id: overlayCipherId, - name: cipher.name, - type: cipher.type, - reprompt: cipher.reprompt, - favorite: cipher.favorite, - icon: - cipher.type === CipherType.Login - ? loginCipherIcon - : buildCipherIcon(this.iconsServerUrl, cipher, showFavicons), - login: cipher.type === CipherType.Login ? { username: cipher.login.username } : null, - card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, - }); - } - - return overlayCipherData; - } - - /** - * Handles aggregation of page details for a tab. Stores the page details - * in association with the tabId of the tab that sent the message. - * - * @param message - Message received from the `collectPageDetailsResponse` command - * @param sender - The sender of the message - */ - private storePageDetails( - message: OverlayBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, - ) { - const pageDetails = { - frameId: sender.frameId, - tab: sender.tab, - details: message.details, - }; - - const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; - if (!pageDetailsMap) { - this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); - return; - } - - pageDetailsMap.set(sender.frameId, pageDetails); - } - - /** - * Triggers autofill for the selected cipher in the overlay list. Also places - * the selected cipher at the top of the list of ciphers. - * - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. - * @param sender - The sender of the port message - */ - private async fillSelectedOverlayListItem( - { overlayCipherId }: OverlayPortMessage, - { sender }: chrome.runtime.Port, - ) { - const pageDetails = this.pageDetailsForTab[sender.tab.id]; - if (!overlayCipherId || !pageDetails?.size) { - return; - } - - const cipher = this.overlayLoginCiphers.get(overlayCipherId); - - if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { - return; - } - const totpCode = await this.autofillService.doAutoFill({ - tab: sender.tab, - cipher: cipher, - pageDetails: Array.from(pageDetails.values()), - fillNewPassword: true, - allowTotpAutofill: true, - }); - - if (totpCode) { - this.platformUtilsService.copyToClipboard(totpCode); - } - - this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); - } - - /** - * Checks if the overlay is focused. Will check the overlay list - * if it is open, otherwise it will check the overlay button. - */ - private checkOverlayFocused() { - if (this.overlayListPort) { - this.checkOverlayListFocused(); - - return; - } - - this.checkOverlayButtonFocused(); - } - - /** - * Posts a message to the overlay button iframe to check if it is focused. - */ - private checkOverlayButtonFocused() { - this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" }); - } - - /** - * Posts a message to the overlay list iframe to check if it is focused. - */ - private checkOverlayListFocused() { - this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" }); - } - - /** - * Sends a message to the sender tab to close the autofill overlay. - * - * @param sender - The sender of the port message - * @param forceCloseOverlay - Identifies whether the overlay should be force closed - */ - private closeOverlay({ sender }: chrome.runtime.Port, forceCloseOverlay = false) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "closeAutofillOverlay", { forceCloseOverlay }); - } - - /** - * Handles cleanup when an overlay element is closed. Disconnects - * the list and button ports and sets them to null. - * - * @param overlayElement - The overlay element that was closed, either the list or button - * @param sender - The sender of the port message - */ - private overlayElementClosed( - { overlayElement }: OverlayBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, - ) { - if (sender.tab.id !== this.focusedFieldData?.tabId) { - this.expiredPorts.forEach((port) => port.disconnect()); - this.expiredPorts = []; - return; - } - - if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.disconnect(); - this.overlayButtonPort = null; - - return; - } - - this.overlayListPort?.disconnect(); - this.overlayListPort = null; - } - - /** - * Updates the position of either the overlay list or button. The position - * is based on the focused field's position and dimensions. - * - * @param overlayElement - The overlay element to update, either the list or button - * @param sender - The sender of the port message - */ - private updateOverlayPosition( - { overlayElement }: { overlayElement?: string }, - sender: chrome.runtime.MessageSender, - ) { - if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) { - return; - } - - if (overlayElement === AutofillOverlayElement.Button) { - this.overlayButtonPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayButtonPosition(), - }); - - return; - } - - this.overlayListPort?.postMessage({ - command: "updateIframePosition", - styles: this.getOverlayListPosition(), - }); - } - - /** - * Gets the position of the focused field and calculates the position - * of the overlay button based on the focused field's position and dimensions. - */ - private getOverlayButtonPosition() { - if (!this.focusedFieldData) { - return; - } - - const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; - const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles; - let elementOffset = height * 0.37; - if (height >= 35) { - elementOffset = height >= 50 ? height * 0.47 : height * 0.42; - } - - const elementHeight = height - elementOffset; - const elementTopPosition = top + elementOffset / 2; - let elementLeftPosition = left + width - height + elementOffset / 2; - - const fieldPaddingRight = parseInt(paddingRight, 10); - const fieldPaddingLeft = parseInt(paddingLeft, 10); - if (fieldPaddingRight > fieldPaddingLeft) { - elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2); - } - - return { - top: `${Math.round(elementTopPosition)}px`, - left: `${Math.round(elementLeftPosition)}px`, - height: `${Math.round(elementHeight)}px`, - width: `${Math.round(elementHeight)}px`, - }; - } - - /** - * Gets the position of the focused field and calculates the position - * of the overlay list based on the focused field's position and dimensions. - */ - private getOverlayListPosition() { - if (!this.focusedFieldData) { - return; - } - - const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; - return { - width: `${Math.round(width)}px`, - top: `${Math.round(top + height)}px`, - left: `${Math.round(left)}px`, - }; - } - - /** - * Sets the focused field data to the data passed in the extension message. - * - * @param focusedFieldData - Contains the rects and styles of the focused field. - * @param sender - The sender of the extension message - */ - private setFocusedFieldData( - { focusedFieldData }: OverlayBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, - ) { - this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id }; - } - - /** - * Updates the overlay's visibility based on the display property passed in the extension message. - * - * @param display - The display property of the overlay, either "block" or "none" - */ - private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) { - if (!display) { - return; - } - - const portMessage = { command: "updateOverlayHidden", styles: { display } }; - - this.overlayButtonPort?.postMessage(portMessage); - this.overlayListPort?.postMessage(portMessage); - } - - /** - * Sends a message to the currently active tab to open the autofill overlay. - * - * @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened - * @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states - */ - private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) { - const currentTab = await BrowserApi.getTabFromCurrentWindowId(); - - await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", { - isFocusingFieldElement, - isOpeningFullOverlay, - authStatus: await this.getAuthStatus(), - }); - } - - /** - * Gets the overlay's visibility setting from the settings service. - */ - private async getOverlayVisibility(): Promise { - return await firstValueFrom(this.autofillSettingsService.inlineMenuVisibility$); - } - - /** - * Gets the user's authentication status from the auth service. If the user's - * authentication status has changed, the overlay button's authentication status - * will be updated and the overlay list's ciphers will be updated. - */ - private async getAuthStatus() { - const formerAuthStatus = this.userAuthStatus; - this.userAuthStatus = await this.authService.getAuthStatus(); - - if ( - this.userAuthStatus !== formerAuthStatus && - this.userAuthStatus === AuthenticationStatus.Unlocked - ) { - this.updateOverlayButtonAuthStatus(); - await this.updateOverlayCiphers(); - } - - return this.userAuthStatus; - } - - /** - * Sends a message to the overlay button to update its authentication status. - */ - private updateOverlayButtonAuthStatus() { - this.overlayButtonPort?.postMessage({ - command: "updateOverlayButtonAuthStatus", - authStatus: this.userAuthStatus, - }); - } - - /** - * Handles the overlay button being clicked. If the user is not authenticated, - * the vault will be unlocked. If the user is authenticated, the overlay will - * be opened. - * - * @param port - The port of the overlay button - */ - private handleOverlayButtonClicked(port: chrome.runtime.Port) { - if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.unlockVault(port); - return; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.openOverlay(false, true); - } - - /** - * Facilitates opening the unlock popout window. - * - * @param port - The port of the overlay list - */ - private async unlockVault(port: chrome.runtime.Port) { - const { sender } = port; - - this.closeOverlay(port); - const retryMessage: LockedVaultPendingNotificationsData = { - commandToRetry: { message: { command: "openAutofillOverlay" }, sender }, - target: "overlay.background", - }; - await BrowserApi.tabSendMessageData( - sender.tab, - "addToLockedVaultPendingNotifications", - retryMessage, - ); - await this.openUnlockPopout(sender.tab, true); - } - - /** - * Triggers the opening of a vault item popout window associated - * with the passed cipher ID. - * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. - * @param sender - The sender of the port message - */ - private async viewSelectedCipher( - { overlayCipherId }: OverlayPortMessage, - { sender }: chrome.runtime.Port, - ) { - const cipher = this.overlayLoginCiphers.get(overlayCipherId); - if (!cipher) { - return; - } - - await this.openViewVaultItemPopout(sender.tab, { - cipherId: cipher.id, - action: SHOW_AUTOFILL_BUTTON, - }); - } - - /** - * Facilitates redirecting focus to the overlay list. - */ - private focusOverlayList() { - this.overlayListPort?.postMessage({ command: "focusOverlayList" }); - } - - /** - * Updates the authentication status for the user and opens the overlay if - * a followup command is present in the message. - * - * @param message - Extension message received from the `unlockCompleted` command - */ - private async unlockCompleted(message: OverlayBackgroundExtensionMessage) { - await this.getAuthStatus(); - - if (message.data?.commandToRetry?.message?.command === "openAutofillOverlay") { - await this.openOverlay(true); - } - } - - /** - * Gets the translations for the overlay page. - */ - private getTranslations() { - if (!this.overlayPageTranslations) { - this.overlayPageTranslations = { - locale: BrowserApi.getUILanguage(), - opensInANewWindow: this.i18nService.translate("opensInANewWindow"), - buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), - toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"), - listPageTitle: this.i18nService.translate("bitwardenVault"), - unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), - unlockAccount: this.i18nService.translate("unlockAccount"), - fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), - partialUsername: this.i18nService.translate("partialUsername"), - view: this.i18nService.translate("view"), - noItemsToShow: this.i18nService.translate("noItemsToShow"), - newItem: this.i18nService.translate("newItem"), - addNewVaultItem: this.i18nService.translate("addNewVaultItem"), - }; - } - - return this.overlayPageTranslations; - } - - /** - * Facilitates redirecting focus out of one of the - * overlay elements to elements on the page. - * - * @param direction - The direction to redirect focus to (either "next", "previous" or "current) - * @param sender - The sender of the port message - */ - private redirectOverlayFocusOut( - { direction }: OverlayPortMessage, - { sender }: chrome.runtime.Port, - ) { - if (!direction) { - return; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction }); - } - - /** - * Triggers adding a new vault item from the overlay. Gathers data - * input by the user before calling to open the add/edit window. - * - * @param sender - The sender of the port message - */ - private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { - void BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" }); - } - - /** - * Handles adding a new vault item from the overlay. Gathers data login - * data captured in the extension message. - * - * @param login - The login data captured from the extension message - * @param sender - The sender of the extension message - */ - private async addNewVaultItem( - { login }: OverlayAddNewItemMessage, - sender: chrome.runtime.MessageSender, - ) { - if (!login) { - return; - } - - const uriView = new LoginUriView(); - uriView.uri = login.uri; - - const loginView = new LoginView(); - loginView.uris = [uriView]; - loginView.username = login.username || ""; - loginView.password = login.password || ""; - - const cipherView = new CipherView(); - cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, ""); - cipherView.folderId = null; - cipherView.type = CipherType.Login; - cipherView.login = loginView; - - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - await this.cipherService.setAddEditCipherInfo( - { - cipher: cipherView, - collectionIds: cipherView.collectionIds, - }, - activeUserId, - ); - - await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); - } - - /** - * Sets up the extension message listeners for the overlay. - */ - private setupExtensionMessageListeners() { - BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); - BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect); - } - - /** - * Handles extension messages sent to the extension background. - * - * @param message - The message received from the extension - * @param sender - The sender of the message - * @param sendResponse - The response to send back to the sender - */ - private handleExtensionMessage = ( - message: OverlayBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, - sendResponse: (response?: any) => void, - ) => { - const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; - if (!handler) { - return null; - } - - const messageResponse = handler({ message, sender }); - if (typeof messageResponse === "undefined") { - return null; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); - return true; - }; - - /** - * Handles the connection of a port to the extension background. - * - * @param port - The port that connected to the extension background - */ - private handlePortOnConnect = async (port: chrome.runtime.Port) => { - const isOverlayListPort = port.name === AutofillOverlayPort.List; - const isOverlayButtonPort = port.name === AutofillOverlayPort.Button; - if (!isOverlayListPort && !isOverlayButtonPort) { - return; - } - - this.storeOverlayPort(port); - port.onMessage.addListener(this.handleOverlayElementPortMessage); - port.postMessage({ - command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, - authStatus: await this.getAuthStatus(), - styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), - theme: await firstValueFrom(this.themeStateService.selectedTheme$), - translations: this.getTranslations(), - ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null, - }); - this.updateOverlayPosition( - { - overlayElement: isOverlayListPort - ? AutofillOverlayElement.List - : AutofillOverlayElement.Button, - }, - port.sender, - ); - }; - - /** - * Stores the connected overlay port and sets up any existing ports to be disconnected. - * - * @param port - The port to store -| */ - private storeOverlayPort(port: chrome.runtime.Port) { - if (port.name === AutofillOverlayPort.List) { - this.storeExpiredOverlayPort(this.overlayListPort); - this.overlayListPort = port; - return; - } - - if (port.name === AutofillOverlayPort.Button) { - this.storeExpiredOverlayPort(this.overlayButtonPort); - this.overlayButtonPort = port; - } - } - - /** - * When registering a new connection, we want to ensure that the port is disconnected. - * This method places an existing port in the expiredPorts array to be disconnected - * at a later time. - * - * @param port - The port to store in the expiredPorts array - */ - private storeExpiredOverlayPort(port: chrome.runtime.Port | null) { - if (port) { - this.expiredPorts.push(port); - } - } - - /** - * Handles messages sent to the overlay list or button ports. - * - * @param message - The message received from the port - * @param port - The port that sent the message - */ - private handleOverlayElementPortMessage = ( - message: OverlayBackgroundExtensionMessage, - port: chrome.runtime.Port, - ) => { - const command = message?.command; - let handler: CallableFunction | undefined; - - if (port.name === AutofillOverlayPort.Button) { - handler = this.overlayButtonPortMessageHandlers[command]; - } - - if (port.name === AutofillOverlayPort.List) { - handler = this.overlayListPortMessageHandlers[command]; - } - - if (!handler) { - return; - } - - handler({ message, port }); - }; -} - -export default LegacyOverlayBackground; diff --git a/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts deleted file mode 100644 index ed422822b36..00000000000 --- a/apps/browser/src/autofill/deprecated/content/abstractions/autofill-init.deprecated.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; - -import AutofillScript from "../../../models/autofill-script"; - -type AutofillExtensionMessage = { - command: string; - tab?: chrome.tabs.Tab; - sender?: string; - fillScript?: AutofillScript; - url?: string; - pageDetailsUrl?: string; - ciphers?: any; - data?: { - authStatus?: AuthenticationStatus; - isFocusingFieldElement?: boolean; - isOverlayCiphersPopulated?: boolean; - direction?: "previous" | "next"; - isOpeningFullOverlay?: boolean; - forceCloseOverlay?: boolean; - autofillOverlayVisibility?: number; - }; -}; - -type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; - -type AutofillExtensionMessageHandlers = { - [key: string]: CallableFunction; - collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; - collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; - fillForm: ({ message }: AutofillExtensionMessageParam) => void; - openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - closeAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; - addNewVaultItemFromOverlay: () => void; - redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; - updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; - bgUnlockPopoutOpened: () => void; - bgVaultItemRepromptPopoutOpened: () => void; - updateAutofillOverlayVisibility: ({ message }: AutofillExtensionMessageParam) => void; -}; - -export { AutofillExtensionMessage, AutofillExtensionMessageHandlers }; diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts deleted file mode 100644 index 96d5e85ca34..00000000000 --- a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; - -import { RedirectFocusDirection } from "../../enums/autofill-overlay.enum"; -import AutofillPageDetails from "../../models/autofill-page-details"; -import AutofillScript from "../../models/autofill-script"; -import { - flushPromises, - mockQuerySelectorAllDefinedCall, - sendMockExtensionMessage, -} from "../../spec/testing-utils"; -import AutofillOverlayContentServiceDeprecated from "../services/autofill-overlay-content.service.deprecated"; - -import { AutofillExtensionMessage } from "./abstractions/autofill-init.deprecated"; -import AutofillInitDeprecated from "./autofill-init.deprecated"; - -describe("AutofillInit", () => { - let autofillInit: AutofillInitDeprecated; - const autofillOverlayContentService = mock(); - const originalDocumentReadyState = document.readyState; - const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); - - beforeEach(() => { - chrome.runtime.connect = jest.fn().mockReturnValue({ - onDisconnect: { - addListener: jest.fn(), - }, - }); - autofillInit = new AutofillInitDeprecated(autofillOverlayContentService); - window.IntersectionObserver = jest.fn(() => mock()); - }); - - afterEach(() => { - jest.resetModules(); - jest.clearAllMocks(); - Object.defineProperty(document, "readyState", { - value: originalDocumentReadyState, - writable: true, - }); - }); - - afterAll(() => { - mockQuerySelectorAll.mockRestore(); - }); - - describe("init", () => { - it("sets up the extension message listeners", () => { - jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); - - autofillInit.init(); - - expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); - }); - - it("triggers a collection of page details if the document is in a `complete` ready state", () => { - jest.useFakeTimers(); - Object.defineProperty(document, "readyState", { value: "complete", writable: true }); - - autofillInit.init(); - jest.advanceTimersByTime(250); - - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( - { - command: "bgCollectPageDetails", - sender: "autofillInit", - }, - expect.any(Function), - ); - }); - - it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { - jest.spyOn(window, "addEventListener"); - Object.defineProperty(document, "readyState", { value: "loading", writable: true }); - - autofillInit.init(); - - expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); - }); - }); - - describe("setupExtensionMessageListeners", () => { - it("sets up a chrome runtime on message listener", () => { - jest.spyOn(chrome.runtime.onMessage, "addListener"); - - autofillInit["setupExtensionMessageListeners"](); - - expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( - autofillInit["handleExtensionMessage"], - ); - }); - }); - - describe("handleExtensionMessage", () => { - let message: AutofillExtensionMessage; - let sender: chrome.runtime.MessageSender; - const sendResponse = jest.fn(); - - beforeEach(() => { - message = { - command: "collectPageDetails", - tab: mock(), - sender: "sender", - }; - sender = mock(); - }); - - it("returns a undefined value if a extension message handler is not found with the given message command", () => { - message.command = "unknownCommand"; - - const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); - - expect(response).toBe(null); - }); - - it("returns a undefined value if the message handler does not return a response", async () => { - const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); - await flushPromises(); - - expect(response1).not.toBe(false); - - message.command = "removeAutofillOverlay"; - message.fillScript = mock(); - - const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); - await flushPromises(); - - expect(response2).toBe(null); - }); - - it("returns a true value and calls sendResponse if the message handler returns a response", async () => { - message.command = "collectPageDetailsImmediately"; - const pageDetails: AutofillPageDetails = { - title: "title", - url: "http://example.com", - documentUrl: "documentUrl", - forms: {}, - fields: [], - collectedTimestamp: 0, - }; - jest - .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") - .mockResolvedValue(pageDetails); - - const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); - await flushPromises(); - - expect(response).toBe(true); - expect(sendResponse).toHaveBeenCalledWith(pageDetails); - }); - - describe("extension message handlers", () => { - beforeEach(() => { - autofillInit.init(); - }); - - describe("collectPageDetails", () => { - it("sends the collected page details for autofill using a background script message", async () => { - const pageDetails: AutofillPageDetails = { - title: "title", - url: "http://example.com", - documentUrl: "documentUrl", - forms: {}, - fields: [], - collectedTimestamp: 0, - }; - const message = { - command: "collectPageDetails", - sender: "sender", - tab: mock(), - }; - jest - .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") - .mockResolvedValue(pageDetails); - - sendMockExtensionMessage(message, sender, sendResponse); - await flushPromises(); - - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "collectPageDetailsResponse", - tab: message.tab, - details: pageDetails, - sender: message.sender, - }); - }); - }); - - describe("collectPageDetailsImmediately", () => { - it("returns collected page details for autofill if set to send the details in the response", async () => { - const pageDetails: AutofillPageDetails = { - title: "title", - url: "http://example.com", - documentUrl: "documentUrl", - forms: {}, - fields: [], - collectedTimestamp: 0, - }; - jest - .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") - .mockResolvedValue(pageDetails); - - sendMockExtensionMessage( - { command: "collectPageDetailsImmediately" }, - sender, - sendResponse, - ); - await flushPromises(); - - expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); - expect(sendResponse).toBeCalledWith(pageDetails); - expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({ - command: "collectPageDetailsResponse", - tab: message.tab, - details: pageDetails, - sender: message.sender, - }); - }); - }); - - describe("fillForm", () => { - let fillScript: AutofillScript; - beforeEach(() => { - fillScript = mock(); - jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation(); - }); - - it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { - const fillScript = mock(); - const message = { - command: "fillForm", - fillScript, - pageDetailsUrl: "https://a-different-url.com", - }; - - sendMockExtensionMessage(message); - await flushPromises(); - - expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( - fillScript, - ); - }); - - it("calls the InsertAutofillContentService to fill the form", async () => { - sendMockExtensionMessage({ - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }); - await flushPromises(); - - expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( - fillScript, - ); - }); - - it("removes the overlay when filling the form", async () => { - const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); - sendMockExtensionMessage({ - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }); - await flushPromises(); - - expect(blurAndRemoveOverlaySpy).toHaveBeenCalled(); - }); - - it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { - jest.useFakeTimers(); - jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); - jest - .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") - .mockImplementation(); - - sendMockExtensionMessage({ - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }); - await flushPromises(); - jest.advanceTimersByTime(300); - - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); - expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( - fillScript, - ); - expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); - }); - - it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { - jest.useFakeTimers(); - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); - jest - .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") - .mockImplementation(); - - sendMockExtensionMessage({ - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }); - await flushPromises(); - jest.advanceTimersByTime(300); - - expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( - 1, - true, - ); - expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( - fillScript, - ); - expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( - 2, - false, - ); - }); - }); - - describe("openAutofillOverlay", () => { - const message = { - command: "openAutofillOverlay", - data: { - isFocusingFieldElement: true, - isOpeningFullOverlay: true, - authStatus: AuthenticationStatus.Unlocked, - }, - }; - - it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("opens the autofill overlay", () => { - sendMockExtensionMessage(message); - - expect( - autofillInit["autofillOverlayContentService"].openAutofillOverlay, - ).toHaveBeenCalledWith({ - isFocusingFieldElement: message.data.isFocusingFieldElement, - isOpeningFullOverlay: message.data.isOpeningFullOverlay, - authStatus: message.data.authStatus, - }); - }); - }); - - describe("closeAutofillOverlay", () => { - beforeEach(() => { - autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; - autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; - }); - - it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: false }, - }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("removes the autofill overlay if the message flags a forced closure", () => { - sendMockExtensionMessage({ - command: "closeAutofillOverlay", - data: { forceCloseOverlay: true }, - }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).toHaveBeenCalled(); - }); - - it("ignores the message if a field is currently focused", () => { - autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; - - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).not.toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).not.toHaveBeenCalled(); - }); - - it("removes the autofill overlay list if the overlay is currently filling", () => { - autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; - - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).not.toHaveBeenCalled(); - }); - - it("removes the entire overlay if the overlay is not currently filling", () => { - sendMockExtensionMessage({ command: "closeAutofillOverlay" }); - - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, - ).not.toHaveBeenCalled(); - expect( - autofillInit["autofillOverlayContentService"].removeAutofillOverlay, - ).toHaveBeenCalled(); - }); - }); - - describe("addNewVaultItemFromOverlay", () => { - it("will not add a new vault item if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("will add a new vault item", () => { - sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); - - expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); - }); - }); - - describe("redirectOverlayFocusOut", () => { - const message = { - command: "redirectOverlayFocusOut", - data: { - direction: RedirectFocusDirection.Next, - }, - }; - - it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("redirects the overlay focus", () => { - sendMockExtensionMessage(message); - - expect( - autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut, - ).toHaveBeenCalledWith(message.data.direction); - }); - }); - - describe("updateIsOverlayCiphersPopulated", () => { - const message = { - command: "updateIsOverlayCiphersPopulated", - data: { - isOverlayCiphersPopulated: true, - }, - }; - - it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - - sendMockExtensionMessage(message); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - }); - - it("updates whether the overlay ciphers are populated", () => { - sendMockExtensionMessage(message); - - expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( - message.data.isOverlayCiphersPopulated, - ); - }); - }); - - describe("bgUnlockPopoutOpened", () => { - it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); - }); - - it("blurs the most recently focused feel and remove the autofill overlay", () => { - jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); - jest.spyOn(autofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" }); - - expect( - autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, - ).toHaveBeenCalled(); - expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); - }); - }); - - describe("bgVaultItemRepromptPopoutOpened", () => { - it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { - const newAutofillInit = new AutofillInitDeprecated(undefined); - newAutofillInit.init(); - jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); - - expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); - expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); - }); - - it("blurs the most recently focused feel and remove the autofill overlay", () => { - jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); - jest.spyOn(autofillInit as any, "removeAutofillOverlay"); - - sendMockExtensionMessage({ command: "bgVaultItemRepromptPopoutOpened" }); - - expect( - autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, - ).toHaveBeenCalled(); - expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); - }); - }); - - describe("updateAutofillOverlayVisibility", () => { - beforeEach(() => { - autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = - AutofillOverlayVisibility.OnButtonClick; - }); - - it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { - sendMockExtensionMessage({ - command: "updateAutofillOverlayVisibility", - data: {}, - }); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - AutofillOverlayVisibility.OnButtonClick, - ); - }); - - it("updates the overlay visibility value", () => { - const message = { - command: "updateAutofillOverlayVisibility", - data: { - autofillOverlayVisibility: AutofillOverlayVisibility.Off, - }, - }; - - sendMockExtensionMessage(message); - - expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( - message.data.autofillOverlayVisibility, - ); - }); - }); - }); - }); - - describe("destroy", () => { - it("clears the timeout used to collect page details on load", () => { - jest.spyOn(window, "clearTimeout"); - - autofillInit.init(); - autofillInit.destroy(); - - expect(window.clearTimeout).toHaveBeenCalledWith( - autofillInit["collectPageDetailsOnLoadTimeout"], - ); - }); - - it("removes the extension message listeners", () => { - autofillInit.destroy(); - - expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( - autofillInit["handleExtensionMessage"], - ); - }); - - it("destroys the collectAutofillContentService", () => { - jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); - - autofillInit.destroy(); - - expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts deleted file mode 100644 index fac9c0852f5..00000000000 --- a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.ts +++ /dev/null @@ -1,315 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { AutofillInit } from "../../content/abstractions/autofill-init"; -import AutofillPageDetails from "../../models/autofill-page-details"; -import { CollectAutofillContentService } from "../../services/collect-autofill-content.service"; -import DomElementVisibilityService from "../../services/dom-element-visibility.service"; -import { DomQueryService } from "../../services/dom-query.service"; -import InsertAutofillContentService from "../../services/insert-autofill-content.service"; -import { sendExtensionMessage } from "../../utils"; -import { LegacyAutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; - -import { - AutofillExtensionMessage, - AutofillExtensionMessageHandlers, -} from "./abstractions/autofill-init.deprecated"; - -class LegacyAutofillInit implements AutofillInit { - private readonly autofillOverlayContentService: LegacyAutofillOverlayContentService | undefined; - private readonly domElementVisibilityService: DomElementVisibilityService; - private readonly collectAutofillContentService: CollectAutofillContentService; - private readonly insertAutofillContentService: InsertAutofillContentService; - private collectPageDetailsOnLoadTimeout: number | NodeJS.Timeout | undefined; - private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = { - collectPageDetails: ({ message }) => this.collectPageDetails(message), - collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), - fillForm: ({ message }) => this.fillForm(message), - openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message), - closeAutofillOverlay: ({ message }) => this.removeAutofillOverlay(message), - addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), - redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), - updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), - bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), - bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), - updateAutofillOverlayVisibility: ({ message }) => this.updateAutofillOverlayVisibility(message), - }; - - /** - * AutofillInit constructor. Initializes the DomElementVisibilityService, - * CollectAutofillContentService and InsertAutofillContentService classes. - * - * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. - */ - constructor(autofillOverlayContentService?: LegacyAutofillOverlayContentService) { - this.autofillOverlayContentService = autofillOverlayContentService; - this.domElementVisibilityService = new DomElementVisibilityService(); - const domQueryService = new DomQueryService(); - this.collectAutofillContentService = new CollectAutofillContentService( - this.domElementVisibilityService, - domQueryService, - this.autofillOverlayContentService, - ); - this.insertAutofillContentService = new InsertAutofillContentService( - this.domElementVisibilityService, - this.collectAutofillContentService, - ); - } - - /** - * Initializes the autofill content script, setting up - * the extension message listeners. This method should - * be called once when the content script is loaded. - */ - init() { - this.setupExtensionMessageListeners(); - this.autofillOverlayContentService?.init(); - this.collectPageDetailsOnLoad(); - } - - /** - * Triggers a collection of the page details from the - * background script, ensuring that autofill is ready - * to act on the page. - */ - private collectPageDetailsOnLoad() { - const sendCollectDetailsMessage = () => { - this.clearCollectPageDetailsOnLoadTimeout(); - this.collectPageDetailsOnLoadTimeout = setTimeout( - () => sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), - 250, - ); - }; - - if (globalThis.document.readyState === "complete") { - sendCollectDetailsMessage(); - } - - globalThis.addEventListener("load", sendCollectDetailsMessage); - } - - /** - * Collects the page details and sends them to the - * extension background script. If the `sendDetailsInResponse` - * parameter is set to true, the page details will be - * returned to facilitate sending the details in the - * response to the extension message. - * - * @param message - The extension message. - * @param sendDetailsInResponse - Determines whether to send the details in the response. - */ - private async collectPageDetails( - message: AutofillExtensionMessage, - sendDetailsInResponse = false, - ): Promise { - const pageDetails: AutofillPageDetails = - await this.collectAutofillContentService.getPageDetails(); - if (sendDetailsInResponse) { - return pageDetails; - } - - void chrome.runtime.sendMessage({ - command: "collectPageDetailsResponse", - tab: message.tab, - details: pageDetails, - sender: message.sender, - }); - } - - /** - * Fills the form with the given fill script. - * - * @param {AutofillExtensionMessage} message - */ - private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) { - if ((document.defaultView || window).location.href !== pageDetailsUrl) { - return; - } - - this.blurAndRemoveOverlay(); - this.updateOverlayIsCurrentlyFilling(true); - await this.insertAutofillContentService.fillForm(fillScript); - - if (!this.autofillOverlayContentService) { - return; - } - - setTimeout(() => this.updateOverlayIsCurrentlyFilling(false), 250); - } - - /** - * Handles updating the overlay is currently filling value. - * - * @param isCurrentlyFilling - Indicates if the overlay is currently filling - */ - private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling; - } - - /** - * Opens the autofill overlay. - * - * @param data - The extension message data. - */ - private openAutofillOverlay({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.openAutofillOverlay(data); - } - - /** - * Blurs the most recent overlay field and removes the overlay. Used - * in cases where the background unlock or vault item reprompt popout - * is opened. - */ - private blurAndRemoveOverlay() { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.blurMostRecentOverlayField(); - this.removeAutofillOverlay(); - } - - /** - * Removes the autofill overlay if the field is not currently focused. - * If the autofill is currently filling, only the overlay list will be - * removed. - */ - private removeAutofillOverlay(message?: AutofillExtensionMessage) { - if (message?.data?.forceCloseOverlay) { - this.autofillOverlayContentService?.removeAutofillOverlay(); - return; - } - - if ( - !this.autofillOverlayContentService || - this.autofillOverlayContentService.isFieldCurrentlyFocused - ) { - return; - } - - if (this.autofillOverlayContentService.isCurrentlyFilling) { - this.autofillOverlayContentService.removeAutofillOverlayList(); - return; - } - - this.autofillOverlayContentService.removeAutofillOverlay(); - } - - /** - * Adds a new vault item from the overlay. - */ - private addNewVaultItemFromOverlay() { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.addNewVaultItem(); - } - - /** - * Redirects the overlay focus out of an overlay iframe. - * - * @param data - Contains the direction to redirect the focus. - */ - private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction); - } - - /** - * Updates whether the current tab has ciphers that can populate the overlay list - * - * @param data - Contains the isOverlayCiphersPopulated value - * - */ - private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService) { - return; - } - - this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean( - data?.isOverlayCiphersPopulated, - ); - } - - /** - * Updates the autofill overlay visibility. - * - * @param data - Contains the autoFillOverlayVisibility value - */ - private updateAutofillOverlayVisibility({ data }: AutofillExtensionMessage) { - if (!this.autofillOverlayContentService || isNaN(data?.autofillOverlayVisibility)) { - return; - } - - this.autofillOverlayContentService.autofillOverlayVisibility = data?.autofillOverlayVisibility; - } - - /** - * Clears the send collect details message timeout. - */ - private clearCollectPageDetailsOnLoadTimeout() { - if (this.collectPageDetailsOnLoadTimeout) { - clearTimeout(this.collectPageDetailsOnLoadTimeout); - } - } - - /** - * Sets up the extension message listeners for the content script. - */ - private setupExtensionMessageListeners() { - chrome.runtime.onMessage.addListener(this.handleExtensionMessage); - } - - /** - * Handles the extension messages sent to the content script. - * - * @param message - The extension message. - * @param sender - The message sender. - * @param sendResponse - The send response callback. - */ - private handleExtensionMessage = ( - message: AutofillExtensionMessage, - sender: chrome.runtime.MessageSender, - sendResponse: (response?: any) => void, - ): boolean => { - const command: string = message.command; - const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; - if (!handler) { - return null; - } - - const messageResponse = handler({ message, sender }); - if (typeof messageResponse === "undefined") { - return null; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve(messageResponse).then((response) => sendResponse(response)); - return true; - }; - - /** - * Handles destroying the autofill init content script. Removes all - * listeners, timeouts, and object instances to prevent memory leaks. - */ - destroy() { - this.clearCollectPageDetailsOnLoadTimeout(); - chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); - this.collectAutofillContentService.destroy(); - this.autofillOverlayContentService?.destroy(); - } -} - -export default LegacyAutofillInit; diff --git a/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts b/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts deleted file mode 100644 index 66d672172ae..00000000000 --- a/apps/browser/src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { setupAutofillInitDisconnectAction } from "../../utils"; -import LegacyAutofillOverlayContentService from "../services/autofill-overlay-content.service.deprecated"; - -import LegacyAutofillInit from "./autofill-init.deprecated"; - -(function (windowContext) { - if (!windowContext.bitwardenAutofillInit) { - const autofillOverlayContentService = new LegacyAutofillOverlayContentService(); - windowContext.bitwardenAutofillInit = new LegacyAutofillInit(autofillOverlayContentService); - setupAutofillInitDisconnectAction(windowContext); - - windowContext.bitwardenAutofillInit.init(); - } -})(window); diff --git a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts deleted file mode 100644 index b6b22be9439..00000000000 --- a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-button.deprecated.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; - -type OverlayButtonMessage = { command: string; colorScheme?: string }; - -type UpdateAuthStatusMessage = OverlayButtonMessage & { authStatus: AuthenticationStatus }; - -type InitAutofillOverlayButtonMessage = UpdateAuthStatusMessage & { - styleSheetUrl: string; - translations: Record; -}; - -type OverlayButtonWindowMessageHandlers = { - [key: string]: CallableFunction; - initAutofillOverlayButton: ({ message }: { message: InitAutofillOverlayButtonMessage }) => void; - checkAutofillOverlayButtonFocused: () => void; - updateAutofillOverlayButtonAuthStatus: ({ - message, - }: { - message: UpdateAuthStatusMessage; - }) => void; - updateOverlayPageColorScheme: ({ message }: { message: OverlayButtonMessage }) => void; -}; - -export { - UpdateAuthStatusMessage, - OverlayButtonMessage, - InitAutofillOverlayButtonMessage, - OverlayButtonWindowMessageHandlers, -}; diff --git a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts deleted file mode 100644 index 0c4160a0709..00000000000 --- a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-iframe.service.deprecated.ts +++ /dev/null @@ -1,33 +0,0 @@ -type AutofillOverlayIframeExtensionMessage = { - command: string; - styles?: Partial; - theme?: string; -}; - -type AutofillOverlayIframeWindowMessageHandlers = { - [key: string]: CallableFunction; - updateAutofillOverlayListHeight: (message: AutofillOverlayIframeExtensionMessage) => void; - getPageColorScheme: () => void; -}; - -type AutofillOverlayIframeExtensionMessageParam = { - message: AutofillOverlayIframeExtensionMessage; -}; - -type BackgroundPortMessageHandlers = { - [key: string]: CallableFunction; - initAutofillOverlayList: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; - updateIframePosition: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; - updateOverlayHidden: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; -}; - -interface AutofillOverlayIframeService { - initOverlayIframe(initStyles: Partial, ariaAlert?: string): void; -} - -export { - AutofillOverlayIframeExtensionMessage, - AutofillOverlayIframeWindowMessageHandlers, - BackgroundPortMessageHandlers, - AutofillOverlayIframeService, -}; diff --git a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts deleted file mode 100644 index 83578b13043..00000000000 --- a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-list.deprecated.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; - -import { OverlayCipherData } from "../../background/abstractions/overlay.background.deprecated"; - -type OverlayListMessage = { command: string }; - -type UpdateOverlayListCiphersMessage = OverlayListMessage & { - ciphers: OverlayCipherData[]; -}; - -type InitAutofillOverlayListMessage = OverlayListMessage & { - authStatus: AuthenticationStatus; - styleSheetUrl: string; - theme: string; - translations: Record; - ciphers?: OverlayCipherData[]; -}; - -type OverlayListWindowMessageHandlers = { - [key: string]: CallableFunction; - initAutofillOverlayList: ({ message }: { message: InitAutofillOverlayListMessage }) => void; - checkAutofillOverlayListFocused: () => void; - updateOverlayListCiphers: ({ message }: { message: UpdateOverlayListCiphersMessage }) => void; - focusOverlayList: () => void; -}; - -export { - UpdateOverlayListCiphersMessage, - InitAutofillOverlayListMessage, - OverlayListWindowMessageHandlers, -}; diff --git a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts b/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts deleted file mode 100644 index 368ae4e7303..00000000000 --- a/apps/browser/src/autofill/deprecated/overlay/abstractions/autofill-overlay-page-element.deprecated.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button.deprecated"; -import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list.deprecated"; - -type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers; - -type AutofillOverlayPageElementWindowMessage = { - [key: string]: any; - command: string; - overlayCipherId?: string; - height?: number; -}; - -export { WindowMessageHandlers, AutofillOverlayPageElementWindowMessage }; diff --git a/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap b/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap deleted file mode 100644 index 132bd968899..00000000000 --- a/apps/browser/src/autofill/deprecated/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.deprecated.spec.ts.snap +++ /dev/null @@ -1,23 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AutofillOverlayIframeService initOverlayIframe creates an aria alert element if the ariaAlert param is passed 1`] = ` -
- aria alert -
-`; - -exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's attributes 1`] = ` -