From a71ab020c4f4ed6825d1983c44aadc5b02dacaff Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Tue, 4 Nov 2025 12:47:26 -0500 Subject: [PATCH 01/58] PM-27807 remove unused es-lint disable (#17212) --- apps/browser/src/autofill/notification/bar.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index c0b57de612e..8934fe6a031 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -1,5 +1,4 @@ - - + Bitwarden From cef503ee9a0de7a81d5c15cef527effd4e43df19 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:13:10 +0000 Subject: [PATCH 02/58] [deps] SM: Update jest-diff to v30 (#15293) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> --- package-lock.json | 160 ++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 76 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9636184c5ee..e456e257ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -152,7 +152,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.7", - "jest-diff": "29.7.0", + "jest-diff": "30.2.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.6.1", @@ -26136,26 +26136,51 @@ "license": "MIT" }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-diff/node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "license": "MIT" + }, "node_modules/jest-diff/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -26165,25 +26190,23 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-diff/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, "node_modules/jest-docblock": { @@ -26658,6 +26681,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -27225,6 +27264,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-snapshot/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -32171,36 +32226,6 @@ } } }, - "node_modules/nx/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/nx/node_modules/@sinclair/typebox": { - "version": "0.34.38", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", - "license": "MIT" - }, - "node_modules/nx/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/nx/node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -32258,21 +32283,6 @@ "node": ">=8" } }, - "node_modules/nx/node_modules/jest-diff": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", - "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.0.1", - "chalk": "^4.1.2", - "pretty-format": "30.0.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -32318,26 +32328,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nx/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/nx/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/nx/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", diff --git a/package.json b/package.json index c1becca3a31..e224fd00213 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.3", "husky": "9.1.7", - "jest-diff": "29.7.0", + "jest-diff": "30.2.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.6.1", From f556f3b00043da081328929ee987370c5a79642b Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:22:57 -0500 Subject: [PATCH 03/58] Adding the removal from the access intelligence routing canActivate (#17216) --- .../access-intelligence/access-intelligence-routing.module.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts index 2e3c53d8d9f..4a37bea8872 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence-routing.module.ts @@ -9,9 +9,7 @@ const routes: Routes = [ { path: "", pathMatch: "full", redirectTo: "risk-insights" }, { path: "risk-insights", - canActivate: [ - organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports), - ], + canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)], component: RiskInsightsComponent, data: { titleId: "RiskInsights", From 409dbc4c449c2bdf6ce8813465cdaf718cf2a12d Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:41:00 -0800 Subject: [PATCH 04/58] fix(sso-login): [PM-27674] (Auth) Make 'enter' press start sso process if ssoRequired (#17186) If user's email is NOT in the ssoRequiredCache, pressing "enter" takes them to the MP login screen. If the user's email is in the ssoRequiredCache, pressing "enter" starts the SSO login process. Feature Flags enabled: pm-22110-disable-alternate-login-methods --- libs/auth/src/angular/login/login.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index 4e1689b1054..9faa582c071 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -21,7 +21,7 @@ bitInput appAutofocus (input)="onEmailInput($event)" - (keyup.enter)="continuePressed()" + (keyup.enter)="ssoRequired ? handleSsoClick() : continuePressed()" /> From 92118e525d1b76d7cc646de90aaf37ca6804446f Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 4 Nov 2025 13:56:01 -0500 Subject: [PATCH 05/58] [PM-26984] Use medium instead of semibold or bold (#17185) --- .../src/autofill/content/components/buttons/action-button.ts | 2 +- .../content/components/notification/confirmation/message.ts | 2 +- .../content/components/notification/header-message.ts | 2 +- .../content/components/option-selection/option-items.ts | 2 +- .../src/autofill/content/components/rows/action-row.ts | 2 +- .../src/autofill/overlay/inline-menu/pages/list/list.scss | 4 ++-- .../src/autofill/components/autotype-shortcut.component.html | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index b43bed7f96b..73fc1e79ec5 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -68,7 +68,7 @@ const actionButtonStyles = ({ overflow: hidden; text-align: center; text-overflow: ellipsis; - font-weight: 700; + font-weight: 500; ${disabled || isLoading ? ` diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 01a2b783eda..36ea9c1f9d6 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css` ${baseTextStyles} color: ${themes[theme].primary[600]}; - font-weight: 700; + font-weight: 500; cursor: pointer; `; diff --git a/apps/browser/src/autofill/content/components/notification/header-message.ts b/apps/browser/src/autofill/content/components/notification/header-message.ts index 4b6e4722a83..2e51d82dd07 100644 --- a/apps/browser/src/autofill/content/components/notification/header-message.ts +++ b/apps/browser/src/autofill/content/components/notification/header-message.ts @@ -21,5 +21,5 @@ const notificationHeaderMessageStyles = (theme: Theme) => css` color: ${themes[theme].text.main}; font-family: Inter, sans-serif; font-size: 18px; - font-weight: 600; + font-weight: 500; `; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index ceb72905357..58216b6c1b2 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css` user-select: none; padding: 0.375rem ${spacing["3"]}; color: ${themes[theme].text.muted}; - font-weight: 600; + font-weight: 500; `; export const optionsMenuItemMaxWidth = 260; diff --git a/apps/browser/src/autofill/content/components/rows/action-row.ts b/apps/browser/src/autofill/content/components/rows/action-row.ts index 0380f91012a..8f13b166156 100644 --- a/apps/browser/src/autofill/content/components/rows/action-row.ts +++ b/apps/browser/src/autofill/content/components/rows/action-row.ts @@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css` min-height: 40px; text-align: left; color: ${themes[theme].primary["600"]}; - font-weight: 700; + font-weight: 500; > span { display: block; 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 93f5f647ffe..ee9c68ee603 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 @@ -82,7 +82,7 @@ body * { width: 100%; font-family: $font-family-sans-serif; font-size: 1.6rem; - font-weight: 700; + font-weight: 500; text-align: left; background: transparent; border: none; @@ -187,7 +187,7 @@ body * { top: 0; z-index: 1; font-family: $font-family-sans-serif; - font-weight: 600; + font-weight: 500; font-size: 1rem; line-height: 1.3; letter-spacing: 0.025rem; diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.html b/apps/desktop/src/autofill/components/autotype-shortcut.component.html index 774c299e0b6..6f73d4006ac 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.html +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.html @@ -1,6 +1,6 @@
-
+
{{ "typeShortcut" | i18n }}
From d364dfdda07dbba6561a0ee446d938fe2ea78c52 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:59:00 -0800 Subject: [PATCH 06/58] [PM-26182] - [Defect] [Browser] Safari - Autofill on page load default setting is missing yes or no (#16605) * handle parenthesis translation * add whitespace around placeholder with parentheses * fix test * fix label * fix spec --- apps/browser/src/_locales/en/messages.json | 10 ++++++++++ .../autofill-options.component.spec.ts | 4 ++-- .../autofill-options/autofill-options.component.ts | 5 ++++- .../autofill-options/uri-option.component.spec.ts | 6 +++--- .../autofill-options/uri-option.component.ts | 2 +- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a7fe29e85d4..a8743b0db68 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4974,6 +4974,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts index 3aeeac6ca92..f1bb1ef942b 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts @@ -201,12 +201,12 @@ describe("AutofillOptionsComponent", () => { it("updates the default autofill on page load label", () => { fixture.detectChanges(); - expect(component["autofillOptions"][0].label).toEqual("defaultLabel no"); + expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue no"); (autofillSettingsService.autofillOnPageLoadDefault$ as BehaviorSubject).next(true); fixture.detectChanges(); - expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes"); + expect(component["autofillOptions"][0].label).toEqual("defaultLabelWithValue yes"); }); it("hides the autofill on page load field when the setting is disabled", () => { diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index e6b8b5c9aca..7215b1d6c67 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -218,7 +218,10 @@ export class AutofillOptionsComponent implements OnInit { return; } - this.autofillOptions[0].label = this.i18nService.t("defaultLabel", defaultOption.label); + this.autofillOptions[0].label = this.i18nService.t( + "defaultLabelWithValue", + defaultOption.label, + ); // Trigger change detection to update the label in the template this.autofillOptions = [...this.autofillOptions]; }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index 2d06f5dcc29..ed70b4381d2 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -77,19 +77,19 @@ describe("UriOptionComponent", () => { component.defaultMatchDetection = UriMatchStrategy.Domain; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel baseDomain"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue baseDomain"); }); it("should update the default uri match strategy label", () => { component.defaultMatchDetection = UriMatchStrategy.Exact; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel exact"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue exact"); component.defaultMatchDetection = UriMatchStrategy.StartsWith; fixture.detectChanges(); - expect(component["uriMatchOptions"][0].label).toBe("defaultLabel startsWith"); + expect(component["uriMatchOptions"][0].label).toBe("defaultLabelWithValue startsWith"); }); it("should focus the uri input when focusInput is called", () => { diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index b61109a45bb..34ac284c3f3 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -124,7 +124,7 @@ export class UriOptionComponent implements ControlValueAccessor { } this.uriMatchOptions[0].label = this.i18nService.t( - "defaultLabel", + "defaultLabelWithValue", this.uriMatchOptions.find((o) => o.value === value)?.label, ); } From 7e5f02f90c7bdc5a575de471d7c63601ed326750 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 4 Nov 2025 12:15:53 -0800 Subject: [PATCH 07/58] [PM-24469] Implement Risk Insights for Premium in Cipher view component (#17012) * [PM-24469] Refactor CipherViewComponent to use Angular signals and computed properties for improved reactivity * [PM-24469] Refactor CipherViewComponent to utilize Angular signals for organization data retrieval * [PM-24469] Refactor CipherViewComponent to utilize Angular signals for folder data retrieval * [PM-24469] Cleanup organization signal * [PM-24469] Refactor CipherViewComponent to replace signal for card expiration with computed property * [PM-24469] Improve collections loading in CipherViewComponent * [PM-24469] Remove redundant loadCipherData method * [PM-24469] Refactor CipherViewComponent to replace signal with computed property for pending change password tasks * [PM-24469] Refactor LoginCredentialsViewComponent to rename hadPendingChangePasswordTask to showChangePasswordLink for clarity * [PM-24469] Introduce showChangePasswordLink computed property for improved readability * [PM-24469] Initial RI for premium logic * [PM-24469] Refactor checkPassword risk checking logic * [PM-24469] Cleanup premium check * [PM-24469] Cleanup UI visuals * [PM-24469] Fix missing typography import * [PM-24469] Cleanup docs * [PM-24469] Add feature flag * [PM-24469] Ensure password risk check is only performed when the feature is enabled, and the cipher is editable by the user, and it has a password * [PM-24469] Refactor password risk evaluation logic and add unit tests for risk assessment * [PM-24469] Fix mismatched CipherId type * [PM-24469] Fix test dependencies * [PM-24469] Fix config service mock in emergency view dialog spec * [PM-24469] Wait for decrypted vault before calculating cipher risk * [PM-24469] startWith(false) for passwordIsAtRisk signal to avoid showing stale values when cipher changes * [PM-24469] Exclude organization owned ciphers from JIT risk analysis * [PM-24469] Add initial cipher-view component test boilerplate * [PM-24469] Add passwordIsAtRisk signal tests * [PM-24469] Ignore soft deleted items for RI for premium feature * [PM-24469] Fix tests --- apps/desktop/src/locales/en/messages.json | 3 + .../emergency-view-dialog.component.spec.ts | 10 +- apps/web/src/locales/en/messages.json | 3 + .../src/services/jslib-services.module.ts | 7 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../abstractions/cipher-risk.service.spec.ts | 88 +++++ .../vault/abstractions/cipher-risk.service.ts | 24 +- .../src/vault/models/view/cipher.view.ts | 6 + .../default-cipher-risk.service.spec.ts | 63 +++- .../services/default-cipher-risk.service.ts | 11 +- .../cipher-view/cipher-view.component.html | 62 ++-- .../cipher-view/cipher-view.component.spec.ts | 287 +++++++++++++++ .../src/cipher-view/cipher-view.component.ts | 332 +++++++++++------- .../login-credentials-view.component.html | 12 +- .../login-credentials-view.component.ts | 2 +- 15 files changed, 732 insertions(+), 180 deletions(-) create mode 100644 libs/common/src/vault/abstractions/cipher-risk.service.spec.ts create mode 100644 libs/vault/src/cipher-view/cipher-view.component.spec.ts diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index e2032bf27b1..da8d9ea0e34 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index 60993924ded..d13987f2e8b 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -8,6 +8,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; 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"; @@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => { useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, }, { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, + { provide: CipherRiskService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: ConfigService, useValue: mock() }, ], }) .overrideComponent(EmergencyViewDialogComponent, { @@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => { provide: ChangeLoginPasswordService, useValue: ChangeLoginPasswordService, }, - { provide: ConfigService, useValue: ConfigService }, { provide: CipherService, useValue: mock() }, ], }, @@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => { provide: ChangeLoginPasswordService, useValue: mock(), }, - { provide: ConfigService, useValue: mock() }, { provide: CipherService, useValue: mock() }, ], }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index e91464cb174..0a0152c5965 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -23,6 +23,9 @@ "passwordRisk": { "message": "Password Risk" }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "reviewAtRiskPasswords": { "message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 38ce3c0fcc2..c60bc2e2f0b 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -282,6 +282,7 @@ import { } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -303,6 +304,7 @@ import { import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; +import { DefaultCipherRiskService } from "@bitwarden/common/vault/services/default-cipher-risk.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; @@ -605,6 +607,11 @@ const safeProviders: SafeProvider[] = [ MessagingServiceAbstraction, ], }), + safeProvider({ + provide: CipherRiskService, + useClass: DefaultCipherRiskService, + deps: [SdkService, CipherServiceAbstraction], + }), safeProvider({ provide: InternalFolderService, useClass: FolderService, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d9effd21b30..d14ad8a64f3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -58,6 +58,7 @@ export enum FeatureFlag { PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", AutofillConfirmation = "pm-25083-autofill-confirm-from-search", + RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, [FeatureFlag.AutofillConfirmation]: FALSE, + [FeatureFlag.RiskInsightsForPremium]: FALSE, /* Auth */ [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, diff --git a/libs/common/src/vault/abstractions/cipher-risk.service.spec.ts b/libs/common/src/vault/abstractions/cipher-risk.service.spec.ts new file mode 100644 index 00000000000..2c87191cd96 --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-risk.service.spec.ts @@ -0,0 +1,88 @@ +import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal"; + +import { isPasswordAtRisk } from "./cipher-risk.service"; + +describe("isPasswordAtRisk", () => { + const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId; + + const createRisk = (overrides: Partial = {}): CipherRiskResult => ({ + id: mockId, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + ...overrides, + }); + + describe("exposed password risk", () => { + it.each([ + { value: 5, expected: true, desc: "found with value > 0" }, + { value: 0, expected: false, desc: "found but value is 0" }, + ])("should return $expected when password is $desc", ({ value, expected }) => { + const risk = createRisk({ exposed_result: { type: "Found", value } }); + expect(isPasswordAtRisk(risk)).toBe(expected); + }); + + it("should return false when password is not checked", () => { + expect(isPasswordAtRisk(createRisk())).toBe(false); + }); + }); + + describe("password reuse risk", () => { + it.each([ + { count: 2, expected: true, desc: "reused (reuse_count > 1)" }, + { count: 1, expected: false, desc: "not reused" }, + { count: undefined, expected: false, desc: "undefined" }, + ])("should return $expected when reuse_count is $desc", ({ count, expected }) => { + const risk = createRisk({ reuse_count: count }); + expect(isPasswordAtRisk(risk)).toBe(expected); + }); + }); + + describe("password strength risk", () => { + it.each([ + { strength: 0, expected: true }, + { strength: 1, expected: true }, + { strength: 2, expected: true }, + { strength: 3, expected: false }, + { strength: 4, expected: false }, + ])("should return $expected when password strength is $strength", ({ strength, expected }) => { + const risk = createRisk({ password_strength: strength }); + expect(isPasswordAtRisk(risk)).toBe(expected); + }); + }); + + describe("multiple risk factors", () => { + it.each<{ desc: string; overrides: Partial; expected: boolean }>([ + { + desc: "exposed and reused", + overrides: { + exposed_result: { type: "Found" as const, value: 3 }, + reuse_count: 2, + }, + expected: true, + }, + { + desc: "reused and weak strength", + overrides: { password_strength: 2, reuse_count: 2 }, + expected: true, + }, + { + desc: "all three risk factors", + overrides: { + password_strength: 1, + exposed_result: { type: "Found" as const, value: 10 }, + reuse_count: 3, + }, + expected: true, + }, + { + desc: "no risk factors", + overrides: { reuse_count: undefined }, + expected: false, + }, + ])("should return $expected when $desc present", ({ overrides, expected }) => { + const risk = createRisk(overrides); + expect(isPasswordAtRisk(risk)).toBe(expected); + }); + }); +}); diff --git a/libs/common/src/vault/abstractions/cipher-risk.service.ts b/libs/common/src/vault/abstractions/cipher-risk.service.ts index 6bbd9d7791e..78f1c50da19 100644 --- a/libs/common/src/vault/abstractions/cipher-risk.service.ts +++ b/libs/common/src/vault/abstractions/cipher-risk.service.ts @@ -1,12 +1,10 @@ import type { CipherRiskResult, CipherRiskOptions, - ExposedPasswordResult, PasswordReuseMap, - CipherId, } from "@bitwarden/sdk-internal"; -import { UserId } from "../../types/guid"; +import { UserId, CipherId } from "../../types/guid"; import { CipherView } from "../models/view/cipher.view"; export abstract class CipherRiskService { @@ -51,5 +49,21 @@ export abstract class CipherRiskService { abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise; } -// Re-export SDK types for convenience -export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap }; +/** + * Evaluates if a password represented by a CipherRiskResult is considered at risk. + * + * A password is considered at risk if any of the following conditions are true: + * - The password has been exposed in data breaches + * - The password is reused across multiple ciphers + * - The password has weak strength (password_strength < 3) + * + * @param risk - The CipherRiskResult to evaluate + * @returns true if the password is at risk, false otherwise + */ +export function isPasswordAtRisk(risk: CipherRiskResult): boolean { + return ( + (risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) || + (risk.reuse_count ?? 1) > 1 || + risk.password_strength < 3 + ); +} diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 7412c68d695..0d4ab8e5207 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata { return this.passwordHistory && this.passwordHistory.length > 0; } + get hasLoginPassword(): boolean { + return ( + this.type === CipherType.Login && this.login?.password != null && this.login.password !== "" + ); + } + get hasAttachments(): boolean { return !!this.attachments && this.attachments.length > 0; } diff --git a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts index afd52bde6cf..e5231241462 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts @@ -1,11 +1,11 @@ import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, Observable } from "rxjs"; -import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal"; +import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal"; import { asUuid } from "../../platform/abstractions/sdk/sdk.service"; import { MockSdkService } from "../../platform/spec/mock-sdk.service"; -import { UserId } from "../../types/guid"; +import { UserId, CipherId } from "../../types/guid"; import { CipherService } from "../abstractions/cipher.service"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; @@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => { let mockCipherService: jest.Mocked; const mockUserId = "test-user-id" as UserId; - const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; - const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3"; - const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4"; + const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId; + const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId; + const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId; beforeEach(() => { sdkService = new MockSdkService(); @@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => { // Verify password_reuse_map was called twice (fresh computation each time) expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2); }); + + it("should wait for a decrypted vault before computing risk", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "password1"; + + // Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers + const cipherViewsSubject = new BehaviorSubject(null); + mockCipherService.cipherViews$.mockReturnValue( + cipherViewsSubject as Observable, + ); + + mockCipherRiskClient.password_reuse_map.mockReturnValue({}); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]); + + // Initiate the async call but don't await yet + const computePromise = cipherRiskService.computeCipherRiskForUser( + asUuid(mockCipherId1), + mockUserId, + true, + ); + + // Simulate a tick to allow the service to process the null emission + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Now emit the actual decrypted ciphers + cipherViewsSubject.next([cipher]); + + const result = await computePromise; + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [expect.objectContaining({ password: "password1" })], + { + passwordMap: expect.any(Object), + checkExposed: true, + }, + ); + expect(result).toEqual(expect.objectContaining({ id: expect.anything() })); + }); }); }); diff --git a/libs/common/src/vault/services/default-cipher-risk.service.ts b/libs/common/src/vault/services/default-cipher-risk.service.ts index d9f0243edfe..4b4558e5e7a 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.ts @@ -1,16 +1,17 @@ import { firstValueFrom, switchMap } from "rxjs"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { CipherLoginDetails, CipherRiskOptions, PasswordReuseMap, - CipherId, CipherRiskResult, + CipherId as SdkCipherId, } from "@bitwarden/sdk-internal"; import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service"; -import { UserId } from "../../types/guid"; +import { UserId, CipherId } from "../../types/guid"; import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; @@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { checkExposed: boolean = true, ): Promise { // Get all ciphers for the user - const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId)); + const allCiphers = await firstValueFrom( + this.cipherService.cipherViews$(userId).pipe(filterOutNullish()), + ); // Find the specific cipher const targetCipher = allCiphers?.find((c) => asUuid(c.id) === cipherId); @@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { .map( (cipher) => ({ - id: asUuid(cipher.id), + id: asUuid(cipher.id), password: cipher.login.password!, username: cipher.login.username, }) satisfies CipherLoginDetails, diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index b523c11c7e3..3d0cc4c4414 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -1,89 +1,85 @@ - - + + {{ "cardExpiredMessage" | i18n }} {{ "changeAtRiskPasswordAndAddWebsite" | i18n }} - - + + {{ "changeAtRiskPassword" | i18n }} -

+

{{ "noEditPermissions" | i18n }}

- + - + - + - - + + - - + + - + - + diff --git a/libs/vault/src/cipher-view/cipher-view.component.spec.ts b/libs/vault/src/cipher-view/cipher-view.component.spec.ts new file mode 100644 index 00000000000..18a5132781b --- /dev/null +++ b/libs/vault/src/cipher-view/cipher-view.component.spec.ts @@ -0,0 +1,287 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { TaskService } from "@bitwarden/common/vault/tasks"; + +import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service"; + +import { CipherViewComponent } from "./cipher-view.component"; + +describe("CipherViewComponent", () => { + let component: CipherViewComponent; + let fixture: ComponentFixture; + + // Mock services + let mockAccountService: AccountService; + let mockOrganizationService: OrganizationService; + let mockCollectionService: CollectionService; + let mockFolderService: FolderService; + let mockTaskService: TaskService; + let mockPlatformUtilsService: PlatformUtilsService; + let mockChangeLoginPasswordService: ChangeLoginPasswordService; + let mockCipherService: CipherService; + let mockViewPasswordHistoryService: ViewPasswordHistoryService; + let mockI18nService: I18nService; + let mockLogService: LogService; + let mockCipherRiskService: CipherRiskService; + let mockBillingAccountProfileStateService: BillingAccountProfileStateService; + let mockConfigService: ConfigService; + + // Mock data + let mockCipherView: CipherView; + let featureFlagEnabled$: BehaviorSubject; + let hasPremiumFromAnySource$: BehaviorSubject; + let activeAccount$: BehaviorSubject; + + beforeEach(async () => { + // Setup mock observables + activeAccount$ = new BehaviorSubject({ + id: "test-user-id", + email: "test@example.com", + } as Account); + + featureFlagEnabled$ = new BehaviorSubject(false); + hasPremiumFromAnySource$ = new BehaviorSubject(true); + + // Create service mocks + mockAccountService = mock(); + mockAccountService.activeAccount$ = activeAccount$; + + mockOrganizationService = mock(); + mockCollectionService = mock(); + mockFolderService = mock(); + mockTaskService = mock(); + mockPlatformUtilsService = mock(); + mockChangeLoginPasswordService = mock(); + mockCipherService = mock(); + mockViewPasswordHistoryService = mock(); + mockI18nService = mock({ + t: (key: string) => key, + }); + mockLogService = mock(); + mockCipherRiskService = mock(); + + mockBillingAccountProfileStateService = mock(); + mockBillingAccountProfileStateService.hasPremiumFromAnySource$ = jest + .fn() + .mockReturnValue(hasPremiumFromAnySource$); + + mockConfigService = mock(); + mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$); + + // Setup mock cipher view + mockCipherView = new CipherView(); + mockCipherView.id = "cipher-id"; + mockCipherView.name = "Test Cipher"; + + await TestBed.configureTestingModule({ + imports: [CipherViewComponent], + providers: [ + { provide: AccountService, useValue: mockAccountService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: CollectionService, useValue: mockCollectionService }, + { provide: FolderService, useValue: mockFolderService }, + { provide: TaskService, useValue: mockTaskService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: ViewPasswordHistoryService, useValue: mockViewPasswordHistoryService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: LogService, useValue: mockLogService }, + { provide: CipherRiskService, useValue: mockCipherRiskService }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, + { provide: ConfigService, useValue: mockConfigService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + // Override the component template to avoid rendering child components + // Allows testing component logic without + // needing to provide dependencies for all child components. + .overrideComponent(CipherViewComponent, { + set: { + template: "
{{ passwordIsAtRisk() }}
", + imports: [], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CipherViewComponent); + component = fixture.componentInstance; + }); + + describe("passwordIsAtRisk signal", () => { + // Helper to create a cipher view with login credentials + const createLoginCipherView = (): CipherView => { + const cipher = new CipherView(); + cipher.id = "cipher-id"; + cipher.name = "Test Login"; + cipher.type = CipherType.Login; + cipher.edit = true; + cipher.organizationId = undefined; + // Set up login with password so hasLoginPassword returns true + cipher.login = { password: "test-password" } as any; + return cipher; + }; + + beforeEach(() => { + // Reset observables to default values for this test suite + featureFlagEnabled$.next(true); + hasPremiumFromAnySource$.next(true); + + // Setup default mock for computeCipherRiskForUser (individual tests can override) + mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue({ + password_strength: 4, + exposed_result: { type: "NotFound" }, + reuse_count: 1, + }); + + // Recreate the fixture for each test in this suite. + // This ensures that the signal's observable subscribes with the correct + // initial state + fixture = TestBed.createComponent(CipherViewComponent); + component = fixture.componentInstance; + }); + + it("returns false when feature flag is disabled", fakeAsync(() => { + featureFlagEnabled$.next(false); + + const cipher = createLoginCipherView(); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns false when cipher has no login password", fakeAsync(() => { + const cipher = createLoginCipherView(); + cipher.login = {} as any; // No password + + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns false when user does not have edit access", fakeAsync(() => { + const cipher = createLoginCipherView(); + cipher.edit = false; + + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns false when cipher is deleted", fakeAsync(() => { + const cipher = createLoginCipherView(); + cipher.deletedDate = new Date(); + + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns false for organization-owned ciphers", fakeAsync(() => { + const cipher = createLoginCipherView(); + cipher.organizationId = "org-id"; + + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns false when user is not premium", fakeAsync(() => { + hasPremiumFromAnySource$.next(false); + + const cipher = createLoginCipherView(); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + tick(); + + expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + + it("returns true when password is weak", fakeAsync(() => { + // Setup mock to return weak password + const mockRiskyResult = { + password_strength: 2, // Weak password (< 3) + exposed_result: { type: "NotFound" }, + reuse_count: 1, + }; + mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockRiskyResult); + + const cipher = createLoginCipherView(); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + + // Initial value should be false (from startWith(false)) + expect(component.passwordIsAtRisk()).toBe(false); + + // Wait for async operations to complete + tick(); + fixture.detectChanges(); + + // After async completes, should reflect the weak password + expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(true); + })); + + it("returns false when password is strong and not exposed", fakeAsync(() => { + // Setup mock to return safe password + const mockSafeResult = { + password_strength: 4, // Strong password + exposed_result: { type: "NotFound" }, // Not exposed + reuse_count: 1, // Not reused + }; + mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockSafeResult); + + const cipher = createLoginCipherView(); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + + // Initial value should be false + expect(component.passwordIsAtRisk()).toBe(false); + + // Wait for async operations to complete + tick(); + fixture.detectChanges(); + + // Should remain false for safe password + expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled(); + expect(component.passwordIsAtRisk()).toBe(false); + })); + }); +}); diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 15cb7d4651f..043a5a63c49 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -1,30 +1,38 @@ import { CommonModule } from "@angular/common"; -import { Component, Input, OnChanges, OnDestroy } from "@angular/core"; -import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { combineLatest, of, switchMap, map, catchError, from, Observable, startWith } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; import { CipherId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; +import { + CipherRiskService, + isPasswordAtRisk, +} from "@bitwarden/common/vault/abstractions/cipher-risk.service"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { AnchorLinkDirective, CalloutModule, SearchModule } from "@bitwarden/components"; +import { + CalloutModule, + SearchModule, + TypographyModule, + AnchorLinkDirective, +} from "@bitwarden/components"; import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service"; @@ -39,11 +47,10 @@ import { LoginCredentialsViewComponent } from "./login-credentials/login-credent import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-cipher-view", templateUrl: "cipher-view.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CalloutModule, CommonModule, @@ -60,38 +67,37 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide LoginCredentialsViewComponent, AutofillOptionsViewComponent, AnchorLinkDirective, + TypographyModule, ], }) -export class CipherViewComponent implements OnChanges, OnDestroy { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ required: true }) cipher: CipherView | null = null; +export class CipherViewComponent { + /** + * The cipher to display details for + */ + readonly cipher = input.required(); - // Required for fetching attachment data when viewed from cipher via emergency access - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() emergencyAccessId?: EmergencyAccessId; + /** + * Observable version of the cipher input + */ + private readonly cipher$ = toObservable(this.cipher); - activeUserId$ = getUserId(this.accountService.activeAccount$); + /** + * Required for fetching attachment data when viewed from cipher via emergency access + */ + readonly emergencyAccessId = input(); /** * Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the * `CipherService` and the `collectionIds` property of the cipher. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() collections?: CollectionView[]; + readonly collections = input(undefined); - /** Should be set to true when the component is used within the Admin Console */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() isAdminConsole?: boolean = false; + /** + * Should be set to true when the component is used within the Admin Console + */ + readonly isAdminConsole = input(false); - organization$: Observable | undefined; - folder$: Observable | undefined; - private destroyed$: Subject = new Subject(); - cardIsExpired: boolean = false; - hadPendingChangePasswordTask: boolean = false; + readonly activeUserId$ = getUserId(this.accountService.activeAccount$); constructor( private organizationService: OrganizationService, @@ -103,126 +109,206 @@ export class CipherViewComponent implements OnChanges, OnDestroy { private changeLoginPasswordService: ChangeLoginPasswordService, private cipherService: CipherService, private logService: LogService, + private cipherRiskService: CipherRiskService, + private billingAccountService: BillingAccountProfileStateService, + private configService: ConfigService, ) {} - async ngOnChanges() { - if (this.cipher == null) { - return; - } + readonly resolvedCollections = toSignal( + combineLatest([this.activeUserId$, this.cipher$, toObservable(this.collections)]).pipe( + switchMap(([userId, cipher, providedCollections]) => { + // Use provided collections if available + if (providedCollections && providedCollections.length > 0) { + return of(providedCollections); + } + // Otherwise, load collections based on cipher's collectionIds + if (cipher.collectionIds && cipher.collectionIds.length > 0) { + return this.collectionService + .decryptedCollections$(userId) + .pipe(getByIds(cipher.collectionIds)); + } + return of(undefined); + }), + ), + ); - await this.loadCipherData(); + readonly organization = toSignal( + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + if (!userId || !cipher?.organizationId) { + return of(undefined); + } + return this.organizationService.organizations$(userId).pipe( + map((organizations) => { + return organizations.find((org) => org.id === cipher.organizationId); + }), + ); + }), + ), + ); + readonly folder = toSignal( + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + if (!userId || !cipher?.folderId) { + return of(undefined); + } + return this.folderService.getDecrypted$(cipher.folderId, userId); + }), + ), + ); - this.cardIsExpired = isCardExpired(this.cipher.card); - } + readonly hadPendingChangePasswordTask = toSignal( + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + // Early exit if not a Login cipher owned by an organization + if (cipher?.type !== CipherType.Login || !cipher?.organizationId) { + return of(false); + } - ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - } + return combineLatest([ + this.cipherService.ciphers$(userId), + this.defaultTaskService.pendingTasks$(userId), + ]).pipe( + map(([allCiphers, tasks]) => { + const cipherServiceCipher = allCiphers[cipher?.id as CipherId]; - get hasCard() { - if (!this.cipher) { + // Show tasks only for Manage and Edit permissions + if (!cipherServiceCipher?.edit || !cipherServiceCipher?.viewPassword) { + return false; + } + + return ( + tasks?.some( + (task) => + task.cipherId === cipher?.id && + task.type === SecurityTaskType.UpdateAtRiskCredential, + ) ?? false + ); + }), + catchError((error: unknown) => { + this.logService.error("Failed to retrieve change password tasks for cipher", error); + return of(false); + }), + ); + }), + ), + { initialValue: false }, + ); + + readonly hasCard = computed(() => { + const cipher = this.cipher(); + if (!cipher) { return false; } - const { cardholderName, code, expMonth, expYear, number } = this.cipher.card; + const { cardholderName, code, expMonth, expYear, number } = cipher.card; return cardholderName || code || expMonth || expYear || number; - } + }); - get hasLogin() { - if (!this.cipher) { + readonly cardIsExpired = computed(() => { + const cipher = this.cipher(); + if (cipher == null) { + return false; + } + return isCardExpired(cipher.card); + }); + + readonly hasLogin = computed(() => { + const cipher = this.cipher(); + if (!cipher) { return false; } - const { username, password, totp, fido2Credentials } = this.cipher.login; + const { username, password, totp, fido2Credentials } = cipher.login; return username || password || totp || fido2Credentials?.length > 0; - } + }); - get hasAutofill() { - const uris = this.cipher?.login?.uris.length ?? 0; + readonly hasAutofill = computed(() => { + const cipher = this.cipher(); + const uris = cipher?.login?.uris.length ?? 0; return uris > 0; - } + }); - get hasSshKey() { - return !!this.cipher?.sshKey?.privateKey; - } + readonly hasSshKey = computed(() => { + const cipher = this.cipher(); + return !!cipher?.sshKey?.privateKey; + }); - get hasLoginUri() { - return this.cipher?.login?.hasUris; - } + readonly hasLoginUri = computed(() => { + const cipher = this.cipher(); + return cipher?.login?.hasUris; + }); - async loadCipherData() { - if (!this.cipher) { - return; - } - - const userId = await firstValueFrom(this.activeUserId$); - - // Load collections if not provided and the cipher has collectionIds - if ( - this.cipher.collectionIds && - this.cipher.collectionIds.length > 0 && - (!this.collections || this.collections.length === 0) - ) { - this.collections = await firstValueFrom( - this.collectionService - .decryptedCollections$(userId) - .pipe(getByIds(this.cipher.collectionIds)), - ); - } - - if (this.cipher.organizationId) { - this.organization$ = this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.cipher.organizationId)) - .pipe(takeUntil(this.destroyed$)); - - if (this.cipher.type === CipherType.Login) { - await this.checkPendingChangePasswordTasks(userId); - } - } - - if (this.cipher.folderId) { - this.folder$ = this.folderService - .getDecrypted$(this.cipher.folderId, userId) - .pipe(takeUntil(this.destroyed$)); - } - } - - async checkPendingChangePasswordTasks(userId: UserId): Promise { - try { - // Show Tasks for Manage and Edit permissions - // Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions - const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); - const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId]; - - if (!cipherServiceCipher?.edit || !cipherServiceCipher?.viewPassword) { - this.hadPendingChangePasswordTask = false; - return; - } - - const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId)); - - this.hadPendingChangePasswordTask = tasks?.some((task) => { - return ( - task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential + /** + * Whether the login password for the cipher is considered at risk. + * The password is only evaluated when the user is premium and has edit access to the cipher. + */ + readonly passwordIsAtRisk = toSignal( + combineLatest([ + this.activeUserId$, + this.cipher$, + this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium), + ]).pipe( + switchMap(([userId, cipher, featureEnabled]) => { + if ( + !featureEnabled || + !cipher.hasLoginPassword || + !cipher.edit || + cipher.organizationId || + cipher.isDeleted + ) { + return of(false); + } + return this.switchPremium$( + userId, + () => + from(this.checkIfPasswordIsAtRisk(cipher.id as CipherId, userId as UserId)).pipe( + startWith(false), + ), + () => of(false), ); - }); - } catch (error) { - this.hadPendingChangePasswordTask = false; - this.logService.error("Failed to retrieve change password tasks for cipher", error); - } - } + }), + ), + { initialValue: false }, + ); + + readonly showChangePasswordLink = computed(() => { + return this.hasLoginUri() && (this.hadPendingChangePasswordTask() || this.passwordIsAtRisk()); + }); launchChangePassword = async () => { - if (this.cipher != null) { - const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher); + const cipher = this.cipher(); + if (cipher != null) { + const url = await this.changeLoginPasswordService.getChangePasswordUrl(cipher); if (url == null) { return; } this.platformUtilsService.launchUri(url); } }; + + /** + * Switches between two observables based on whether the user has a premium from any source. + */ + private switchPremium$( + userId: UserId, + ifPremium$: () => Observable, + ifNonPremium$: () => Observable, + ): Observable { + return this.billingAccountService + .hasPremiumFromAnySource$(userId) + .pipe(switchMap((isPremium) => (isPremium ? ifPremium$() : ifNonPremium$()))); + } + + private async checkIfPasswordIsAtRisk(cipherId: CipherId, userId: UserId): Promise { + try { + const risk = await this.cipherRiskService.computeCipherRiskForUser(cipherId, userId, true); + return isPasswordAtRisk(risk); + } catch (error: unknown) { + this.logService.error("Failed to check if password is at risk", error); + return false; + } + } } diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 02b8be552bd..be33f7a5562 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -90,13 +90,13 @@ data-testid="copy-password" (click)="logCopyEvent()" > + + + {{ "changeAtRiskPassword" | i18n }} + + + - - - {{ "changeAtRiskPassword" | i18n }} - - -
(); From 9bd7b58f6b4735829db66d7563f9f14c5482c1d3 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 4 Nov 2025 15:27:13 -0500 Subject: [PATCH 08/58] [PM-26984] Use medium instead of semibold or bold (#17188) --- .../src/auth/popup/account-switching/account.component.html | 2 +- apps/browser/src/auth/popup/components/set-pin.component.html | 2 +- apps/desktop/src/auth/components/set-pin.component.html | 2 +- .../two-factor/two-factor-setup-webauthn.component.html | 4 ++-- .../two-factor/two-factor-setup-yubikey.component.html | 2 +- .../auth/settings/two-factor/two-factor-setup.component.html | 2 +- .../webauthn-login-settings.component.html | 2 +- apps/web/src/connectors/duo-redirect.ts | 2 +- apps/web/src/connectors/webauthn-fallback.html | 2 +- apps/web/src/connectors/webauthn-mobile.html | 2 +- apps/web/src/connectors/webauthn.html | 2 +- .../device-management-item-group.component.html | 4 ++-- .../environment-selector/environment-selector.component.html | 2 +- .../src/angular/input-password/input-password.component.html | 2 +- .../login-via-auth-request.component.html | 4 ++-- .../registration-start/registration-start.component.html | 2 +- .../user-verification-form-input.component.html | 2 +- 17 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/browser/src/auth/popup/account-switching/account.component.html b/apps/browser/src/auth/popup/account-switching/account.component.html index d22ce9c9366..90770bb8d9b 100644 --- a/apps/browser/src/auth/popup/account-switching/account.component.html +++ b/apps/browser/src/auth/popup/account-switching/account.component.html @@ -25,7 +25,7 @@
( - {{ + {{ status.text }} ) diff --git a/apps/browser/src/auth/popup/components/set-pin.component.html b/apps/browser/src/auth/popup/components/set-pin.component.html index d525f9378f1..c88274b2bf4 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.html +++ b/apps/browser/src/auth/popup/components/set-pin.component.html @@ -1,6 +1,6 @@ -
+
{{ "setYourPinTitle" | i18n }}
diff --git a/apps/desktop/src/auth/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html index 6fb5829b79a..aaebf7c1cdb 100644 --- a/apps/desktop/src/auth/components/set-pin.component.html +++ b/apps/desktop/src/auth/components/set-pin.component.html @@ -1,6 +1,6 @@ -
+
{{ "unlockWithPin" | i18n }}
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html index eec9f74dd60..c272a8e5b70 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html @@ -17,10 +17,10 @@
  • - + {{ "webAuthnkeyX" | i18n: (i + 1).toString() }} - + {{ k.name }} diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html index dbad422a32e..172646f5d4d 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html @@ -45,7 +45,7 @@
-

{{ "nfcSupport" | i18n }}

+

{{ "nfcSupport" | i18n }}

{{ "twoFactorYubikeySupportsNfc" | i18n }} diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html index 16c3dcb3cda..ee2d4dd7b63 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html @@ -53,7 +53,7 @@

{{ p.name }} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html index 7b1d859fb69..e022558f6b1 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html @@ -34,7 +34,7 @@ - + - - - - diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html index 2ab788a0ef0..42600671e8c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html @@ -2,9 +2,7 @@ class="tw-w-full tw-max-w-4xl tw-p-6 sm:tw-p-8 tw-bg-background tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-flex tw-flex-col lg:tw-flex-row tw-gap-6 tw-items-center" >
-
+
{{ title() }}
@@ -22,15 +20,13 @@ class="tw-size-8 sm:tw-size-9 tw-bg-secondary-100 tw-rounded-full tw-flex tw-justify-center tw-items-center tw-flex-shrink-0" >
{{ $index + 1 }}
-
+
{{ benefit[0] }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 2476d79552f..6d030e20066 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -128,10 +128,10 @@ {{ "downloadCSV" | i18n }}
-
+
{{ "email" | i18n }}
-
+
{{ "atRiskPasswords" | i18n }}
@@ -192,10 +192,10 @@ {{ "downloadCSV" | i18n }}
-
+
{{ "application" | i18n }}
-
+
{{ "atRiskPasswords" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html index 19a12755ca0..792606cbfe0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html @@ -18,7 +18,7 @@ @if (linkURL) { -

+

{{ name }} @if (showConnectedBadge()) { @@ -42,7 +42,7 @@ }

@if (description) { -

{{ description }}

+

{{ description }}

} @if (canSetupConnection) { + @if (variantValue === "tree") { + + + + } - + @if (variantValue !== "tree") { + + } diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index 3408af3d734..5797d34da5d 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -34,7 +34,8 @@ import { SideNavService } from "./side-nav.service"; imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe], }) export class NavGroupComponent extends NavBaseComponent { - readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true }); + // Query direct children for hideIfEmpty functionality + readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false }); readonly sideNavOpen = toSignal(this.sideNavService.open$); @@ -47,6 +48,18 @@ export class NavGroupComponent extends NavBaseComponent { return this.hideActiveStyles() || this.sideNavAndGroupOpen(); }); + /** + * Determines the appropriate icon for the toggle button based on variant and open state. + * - Tree variant: Always uses 'bwi-up-solid' + * - Default variant: Uses 'bwi-angle-up' when open, 'bwi-angle-down' when closed + */ + readonly toggleButtonIcon = computed(() => { + if (this.variant() === "tree") { + return "bwi-up-solid"; + } + return this.open() ? "bwi-angle-up" : "bwi-angle-down"; + }); + /** * Allow overriding of the RouterLink['ariaCurrentWhenActive'] property. * @@ -89,14 +102,20 @@ export class NavGroupComponent extends NavBaseComponent { @Optional() @SkipSelf() private parentNavGroup: NavGroupComponent, ) { super(); + + // Set tree depth based on parent's depth + // Both NavGroups and NavItems use constructor-based depth initialization + if (this.parentNavGroup) { + this.treeDepth.set(this.parentNavGroup.treeDepth() + 1); + } } setOpen(isOpen: boolean) { this.open.set(isOpen); this.openChange.emit(this.open()); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.open() && this.parentNavGroup?.setOpen(this.open()); + if (this.open()) { + this.parentNavGroup?.setOpen(this.open()); + } } protected toggle(event?: MouseEvent) { diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index 9910c99fab4..fc9df19a3e2 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -132,3 +132,24 @@ export const Secondary: StoryObj = { `, }), }; + +export const Tree: StoryObj = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + + + + + + + + + `, + }), +}; diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 10f68145a4d..9f6ea3373b7 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -2,6 +2,12 @@ @let open = sideNavService.open$ | async; @if (open || icon()) {
+ @if (open) { +
+ +
+ }
- + @if (icon()) { + + } @if (open) { {{ text() }} } @@ -37,6 +56,7 @@ >; } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -22,6 +23,18 @@ export abstract class NavGroupAbstraction { imports: [CommonModule, IconButtonModule, RouterModule], }) export class NavItemComponent extends NavBaseComponent { + /** + * Base padding for tree variant items (in rem) + * This provides the initial indentation for tree items before depth-based padding + */ + protected readonly TREE_BASE_PADDING = 1.25; + + /** + * Padding increment per tree depth level (in rem) + * Each nested level adds this amount of padding to visually indicate hierarchy + */ + protected readonly TREE_DEPTH_PADDING = 1.25; + /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ readonly forceActiveStyles = input(false); @@ -78,5 +91,10 @@ export class NavItemComponent extends NavBaseComponent { @Optional() private parentNavGroup: NavGroupAbstraction, ) { super(); + + // Set tree depth based on parent's depth + if (this.parentNavGroup) { + this.treeDepth.set(this.parentNavGroup.treeDepth() + 1); + } } } From c404ee210baedd82bb10b644c3271cd17e49519b Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 6 Nov 2025 11:27:46 -0500 Subject: [PATCH 41/58] [PM-26984] Use medium instead of semibold or bold (#17191) --- .../autofill-confirmation-dialog.component.html | 10 +++++----- .../autofill-confirmation-dialog.component.spec.ts | 2 +- .../vault-list-items-container.component.html | 2 +- .../vault/popup/settings/appearance-v2.component.html | 2 +- .../setup-extension/setup-extension.component.html | 2 +- .../components/vault-items/vault-items.component.html | 2 +- .../components/vault-filter.component.html | 2 +- .../components/spotlight/spotlight.component.html | 2 +- .../additional-options-section.component.html | 2 +- .../autofill-options/autofill-options.component.html | 2 +- .../custom-fields/custom-fields.component.html | 2 +- .../new-item-nudge/new-item-nudge.component.ts | 2 +- .../item-history/item-history-v2.component.html | 6 +++--- 13 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html index 625c92e38c5..39ec6bc28a6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -5,7 +5,7 @@ {{ "confirmAutofillDesc" | i18n }}

@if (savedUrls.length === 1) { -

+

{{ "savedWebsite" | i18n }}

@@ -16,14 +16,14 @@ } @if (savedUrls.length > 1) {
-

+

{{ "savedWebsites" | i18n: savedUrls.length }}

} -

+

{{ "currentWebsite" | i18n }}

@@ -61,7 +61,7 @@ bitLink linkType="secondary" (click)="close()" - class="tw-mt-2 tw-font-bold tw-text-sm tw-justify-center tw-text-center" + class="tw-mt-2 tw-font-medium tw-text-sm tw-justify-center tw-text-center" > {{ "doNotAutofill" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts index e8f00cd7b8d..52ab4adcc0c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -199,7 +199,7 @@ describe("AutofillConfirmationDialogComponent", () => { it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => { const findViewAll = () => fixture.nativeElement.querySelector( - "button.tw-text-sm.tw-font-bold.tw-cursor-pointer", + "button.tw-text-sm.tw-font-medium.tw-cursor-pointer", ) as HTMLButtonElement | null; let btn = findViewAll(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index fad5615764c..3dac158b8e1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -84,7 +84,7 @@ -

+

{{ group.subHeaderKey | i18n }}

diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance-v2.component.html index c9598c76db0..b58316a8d64 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.html @@ -41,7 +41,7 @@ {{ "showAnimations" | i18n }} -

{{ "vaultCustomization" | i18n }}

+

{{ "vaultCustomization" | i18n }}

diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 09bd38c8517..038c258d4b6 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -6,7 +6,7 @@ >
-

+

{{ "setupExtensionPageTitle" | i18n }}

diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index d6b5fafe6ec..cb2af9a64e5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -12,7 +12,7 @@ (change)="$event ? toggleAll() : null" [checked]="selection.hasValue() && isAllSelected" /> -

{{ "filters" | i18n }} diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html index 0d0e95e191b..720bf5c1908 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.html +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html @@ -3,7 +3,7 @@ >
-

{{ title }}

+

{{ title }}

- + {{ "addField" | i18n }} diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html index b83685aac94..f71ed1c53d3 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html @@ -29,7 +29,7 @@ (click)="addUri({ uri: null, matchDetection: null }, true)" *ngIf="autofillOptionsForm.enabled" > - + {{ "addWebsite" | i18n }} diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index c5c1b752aef..2ba8b0c76d3 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -119,7 +119,7 @@ data-testid="add-field-button" *ngIf="!isPartialEdit && !parentFormDisabled" > - + {{ "addField" | i18n }} diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts index 5f4a44e5ef5..0c85cad5cfb 100644 --- a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts @@ -72,7 +72,7 @@ export class NewItemNudgeComponent { this.dismissalNudgeType = NudgeType.NewSshItemStatus; this.nudgeTitle = this.i18nService.t("newSshNudgeTitle"); - this.nudgeBody = `${sshPartOne} ${sshPartTwo}`; + this.nudgeBody = `${sshPartOne} ${sshPartTwo}`; return NudgeType.NewSshItemStatus; } default: diff --git a/libs/vault/src/cipher-view/item-history/item-history-v2.component.html b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html index 9395fb34fc7..c5963638b80 100644 --- a/libs/vault/src/cipher-view/item-history/item-history-v2.component.html +++ b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html @@ -4,7 +4,7 @@

- {{ "lastEdited" | i18n }}: + {{ "lastEdited" | i18n }}: {{ cipher.revisionDate | date: "medium" }}

- {{ "dateCreated" | i18n }}: + {{ "dateCreated" | i18n }}: {{ cipher.creationDate | date: "medium" }}

- {{ "datePasswordUpdated" | i18n }}: + {{ "datePasswordUpdated" | i18n }}: {{ cipher.passwordRevisionDisplayDate | date: "medium" }}

-
- } - - @if (renderMode === renderModes.criticalAppsWithAtRiskAppsAndTasks) { -
- {{ "percentageCompleted" | i18n: completedPercent }} -
- -
- {{ - "securityTasksCompleted" | i18n: completedTasksCount : totalTasksCount - }} -
- -
-
-
{{ completedTasksCount }}
-
{{ totalTasksCount }}
+ @switch (currentView()) { + @case (PasswordChangeViewEnum.EMPTY) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }}
-
- - - - +
+ {{ "onceYouReviewApps" | i18n }} +
+ } + + @case (PasswordChangeViewEnum.NO_TASKS_ASSIGNED) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ + "countOfAtRiskPasswords" | i18n: atRiskPasswordCount() + }} +
+ + @if (atRiskPasswordCount() > 0) { +
+ +
+ } + } + + @case (PasswordChangeViewEnum.NEW_TASKS_AVAILABLE) { +
+ {{ "assignMembersTasksToMonitorProgress" | i18n }} +
+ +
+ {{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }} +
+ +
+ +
+ } + + @case (PasswordChangeViewEnum.PROGRESS) { +
+ {{ "percentageCompleted" | i18n: completedTasksPercent() }} +
+ +
+ {{ + "securityTasksCompleted" | i18n: completedTasksCount() : tasksCount() + }} +
+ +
+
+
{{ completedTasksCount() }}
+
{{ tasksCount() }}
+
+
+ + + } }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 5c03534720e..509b3e1314a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -1,197 +1,169 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, DestroyRef, + Injector, OnInit, + Signal, + computed, + effect, inject, + input, + signal, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute } from "@angular/router"; -import { switchMap, of, BehaviorSubject, combineLatest } from "rxjs"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AllActivitiesService, - ApplicationHealthReportDetailEnriched, - SecurityTasksApiService, - TaskMetrics, - OrganizationReportSummary, + RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; +import { + ButtonModule, + ProgressModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; -import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; -import { RenderMode } from "../../models/activity.models"; import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; +export const PasswordChangeView = { + EMPTY: "empty", + NO_TASKS_ASSIGNED: "noTasksAssigned", + NEW_TASKS_AVAILABLE: "newTasks", + PROGRESS: "progress", +} as const; + +export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof PasswordChangeView]; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class PasswordChangeMetricComponent implements OnInit { + PasswordChangeViewEnum = PasswordChangeView; + private destroyRef = inject(DestroyRef); - protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); - private completedTasks: number = 0; - private totalTasks: number = 0; - private allApplicationsDetails: ApplicationHealthReportDetailEnriched[] = []; + // Inputs + // Prefer component input since route param controls UI state + readonly organizationId = input.required(); - atRiskAppsCount: number = 0; - atRiskPasswordsCount: number = 0; - private organizationId!: OrganizationId; - renderMode: RenderMode = "noCriticalApps"; + // Signal states + private readonly _tasks: Signal = signal([]); + private readonly _atRiskCipherIds: Signal = signal([]); + private readonly _hasCriticalApplications: Signal = signal(false); - // Computed properties (formerly getters) - updated when data changes - protected completedPercent = 0; - protected completedTasksCount = 0; - protected totalTasksCount = 0; - protected canAssignTasks = false; - protected hasExistingTasks = false; - protected newAtRiskPasswordsCount = 0; + // Computed properties + readonly tasksCount = computed(() => this._tasks().length); + readonly completedTasksCount = computed( + () => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length, + ); + readonly uncompletedTasksCount = computed( + () => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length, + ); + readonly completedTasksPercent = computed(() => { + const total = this.tasksCount(); + // Account for case where there are no tasks to avoid NaN + return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; + }); + + readonly atRiskPasswordCount = computed(() => { + const atRiskIds = this._atRiskCipherIds(); + const tasks = this._tasks(); + + if (tasks.length === 0) { + return atRiskIds.length; + } + + const assignedIdSet = new Set(tasks.map((task) => task.cipherId)); + const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); + + return unassignedIds.length; + }); + + readonly currentView = computed(() => { + if (!this._hasCriticalApplications()) { + return PasswordChangeView.EMPTY; + } + if (this.tasksCount() === 0) { + return PasswordChangeView.NO_TASKS_ASSIGNED; + } + if (this.atRiskPasswordCount() > 0) { + return PasswordChangeView.NEW_TASKS_AVAILABLE; + } + return PasswordChangeView.PROGRESS; + }); constructor( - private activatedRoute: ActivatedRoute, - private securityTasksApiService: SecurityTasksApiService, private allActivitiesService: AllActivitiesService, - protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, - private cdr: ChangeDetectorRef, - ) {} + private i18nService: I18nService, + private injector: Injector, + private riskInsightsDataService: RiskInsightsDataService, + protected securityTasksService: AccessIntelligenceSecurityTasksService, + private toastService: ToastService, + ) { + // Setup the _tasks signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { + initialValue: [], + injector: this.injector, + }); + // Setup the _atRiskCipherIds signal by manually passing in the injector + this._atRiskCipherIds = toSignal( + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + { + initialValue: [], + injector: this.injector, + }, + ); + + this._hasCriticalApplications = toSignal( + this.riskInsightsDataService.criticalReportResults$.pipe( + takeUntilDestroyed(this.destroyRef), + map((report) => { + return report != null && (report.reportData?.length ?? 0) > 0; + }), + ), + { + initialValue: false, + injector: this.injector, + }, + ); + + effect(() => { + const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS; + this.allActivitiesService.setExtendPasswordWidget(isShowingProgress); + }); + } async ngOnInit(): Promise { - combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) - .pipe( - switchMap(([params, _]) => { - const orgId = params.get("organizationId"); - if (orgId) { - this.organizationId = orgId as OrganizationId; - return this.securityTasksApiService.getTaskMetrics(this.organizationId); - } - return of({ totalTasks: 0, completedTasks: 0 }); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((metrics) => { - this.taskMetrics$.next(metrics); - this.cdr.markForCheck(); - }); - - combineLatest([ - this.taskMetrics$, - this.allActivitiesService.reportSummary$, - this.allActivitiesService.atRiskPasswordsCount$, - this.allActivitiesService.allApplicationsDetails$, - ]) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(([taskMetrics, summary, atRiskPasswordsCount, allApplicationsDetails]) => { - this.atRiskAppsCount = summary.totalCriticalAtRiskApplicationCount; - this.atRiskPasswordsCount = atRiskPasswordsCount; - this.completedTasks = taskMetrics.completedTasks; - this.totalTasks = taskMetrics.totalTasks; - this.allApplicationsDetails = allApplicationsDetails; - - // Determine render mode based on state - this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount); - - this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( - this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, - ); - - // Update all computed properties when data changes - this.updateComputedProperties(); - - this.cdr.markForCheck(); - }); - } - - private determineRenderMode( - summary: OrganizationReportSummary, - taskMetrics: TaskMetrics, - atRiskPasswordsCount: number, - ): RenderMode { - // State 1: No critical apps setup - if (summary.totalCriticalApplicationCount === 0) { - return RenderMode.noCriticalApps; - } - - // State 2: Critical apps with at-risk passwords but no tasks assigned yet - // OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks) - if ( - summary.totalCriticalApplicationCount > 0 && - (taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks) - ) { - return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks; - } - - // State 3: Critical apps with at-risk apps and tasks (progress tracking) - if ( - summary.totalCriticalApplicationCount > 0 && - taskMetrics.totalTasks > 0 && - atRiskPasswordsCount <= taskMetrics.totalTasks - ) { - return RenderMode.criticalAppsWithAtRiskAppsAndTasks; - } - - // Default to no critical apps - return RenderMode.noCriticalApps; - } - - /** - * Updates all computed properties based on current state. - * Called whenever data changes to avoid recalculation on every change detection cycle. - */ - private updateComputedProperties(): void { - // Calculate completion percentage - this.completedPercent = - this.totalTasks === 0 ? 0 : Math.round((this.completedTasks / this.totalTasks) * 100); - - // Calculate completed tasks count based on render mode - switch (this.renderMode) { - case RenderMode.noCriticalApps: - case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - this.completedTasksCount = 0; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - this.completedTasksCount = this.completedTasks; - break; - default: - this.completedTasksCount = 0; - } - - // Calculate total tasks count based on render mode - switch (this.renderMode) { - case RenderMode.noCriticalApps: - this.totalTasksCount = 0; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndNoTasks: - this.totalTasksCount = this.atRiskAppsCount; - break; - case RenderMode.criticalAppsWithAtRiskAppsAndTasks: - this.totalTasksCount = this.totalTasks; - break; - default: - this.totalTasksCount = 0; - } - - // Calculate flags and counts - this.canAssignTasks = this.atRiskPasswordsCount > this.totalTasks; - this.hasExistingTasks = this.totalTasks > 0; - this.newAtRiskPasswordsCount = - this.atRiskPasswordsCount > this.totalTasks ? this.atRiskPasswordsCount - this.totalTasks : 0; - } - - get renderModes() { - return RenderMode; + await this.securityTasksService.loadTasks(this.organizationId()); } async assignTasks() { - await this.accessIntelligenceSecurityTasksService.assignTasks( - this.organizationId, - this.allApplicationsDetails.filter((app) => app.isMarkedAsCritical), - ); + try { + await this.securityTasksService.requestPasswordChangeForCriticalApplications( + this.organizationId(), + this._atRiskCipherIds(), + ); + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + } } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 8cdb927ab65..d0751556517 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -4,8 +4,10 @@
    -
  • - +
  • +
  • diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 7e737be0bdc..06073d93c85 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -1,7 +1,7 @@ -import { Component, DestroyRef, inject, OnInit } from "@angular/core"; +import { Component, DestroyRef, inject, input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, lastValueFrom } from "rxjs"; +import { lastValueFrom } from "rxjs"; import { AllActivitiesService, @@ -10,10 +10,6 @@ import { RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -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 { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -37,13 +33,15 @@ import { NewApplicationsDialogComponent } from "./application-review-dialog/new- templateUrl: "./all-activity.component.html", }) export class AllActivityComponent implements OnInit { - organization: Organization | null = null; + // Prefer component input since route param controls UI state + readonly organizationId = input.required(); + totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; newApplicationsCount = 0; newApplications: ApplicationHealthReportDetail[] = []; - passwordChangeMetricHasProgressBar = false; + extendPasswordChangeWidget = false; allAppsHaveReviewDate = false; isAllCaughtUp = false; hasLoadedApplicationData = false; @@ -53,7 +51,6 @@ export class AllActivityComponent implements OnInit { protected ReportStatusEnum = ReportStatus; constructor( - private accountService: AccountService, protected activatedRoute: ActivatedRoute, protected allActivitiesService: AllActivitiesService, protected dataService: RiskInsightsDataService, @@ -62,53 +59,43 @@ export class AllActivityComponent implements OnInit { ) {} async ngOnInit(): Promise { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.allActivitiesService.reportSummary$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((summary) => { + this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; + this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; + this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; + }); - if (organizationId) { - this.organization = - (await firstValueFrom( - this.organizationService.organizations$(userId).pipe(getById(organizationId)), - )) ?? null; + this.dataService.newApplications$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((newApps) => { + this.newApplications = newApps; + this.newApplicationsCount = newApps.length; + this.updateIsAllCaughtUp(); + }); - this.allActivitiesService.reportSummary$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((summary) => { - this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; - this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; - this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; - }); + this.allActivitiesService.extendPasswordChangeWidget$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((hasProgressBar) => { + this.extendPasswordChangeWidget = hasProgressBar; + }); - this.dataService.newApplications$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((newApps) => { - this.newApplications = newApps; - this.newApplicationsCount = newApps.length; - this.updateIsAllCaughtUp(); - }); - - this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((hasProgressBar) => { - this.passwordChangeMetricHasProgressBar = hasProgressBar; - }); - - this.dataService.enrichedReportData$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((enrichedData) => { - if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) { - this.hasLoadedApplicationData = true; - // Check if all apps have a review date (not null and not undefined) - this.allAppsHaveReviewDate = enrichedData.applicationData.every( - (app) => app.reviewedDate !== null && app.reviewedDate !== undefined, - ); - } else { - this.hasLoadedApplicationData = enrichedData !== null; - this.allAppsHaveReviewDate = false; - } - this.updateIsAllCaughtUp(); - }); - } + this.dataService.enrichedReportData$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((enrichedData) => { + if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) { + this.hasLoadedApplicationData = true; + // Check if all apps have a review date (not null and not undefined) + this.allAppsHaveReviewDate = enrichedData.applicationData.every( + (app) => app.reviewedDate !== null && app.reviewedDate !== undefined, + ); + } else { + this.hasLoadedApplicationData = enrichedData !== null; + this.allAppsHaveReviewDate = false; + } + this.updateIsAllCaughtUp(); + }); } /** diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts index ac1b241a54b..15d927a7714 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -10,9 +10,6 @@ import { import { I18nPipe } from "@bitwarden/ui-common"; import { DarkImageSourceDirective } from "@bitwarden/vault"; -import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; -import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; - /** * Embedded component for displaying task assignment UI. * Not a dialog - intended to be embedded within a parent dialog. @@ -36,7 +33,6 @@ import { AccessIntelligenceSecurityTasksService } from "../../shared/security-ta DarkImageSourceDirective, CalloutComponent, ], - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class AssignTasksViewComponent { readonly criticalApplicationsCount = input.required(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index 8a1a90245b3..e415fbf9ad0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -8,12 +8,10 @@ import { signal, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { from, switchMap } from "rxjs"; +import { from, switchMap, take } from "rxjs"; import { ApplicationHealthReportDetail, - ApplicationHealthReportDetailEnriched, - OrganizationReportApplication, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; @@ -209,40 +207,16 @@ export class NewApplicationsDialogComponent { } this.saving.set(true); - // Create updated organization report application types with new review date - // and critical marking based on selected applications - const newReviewDate = new Date(); - const updatedApplications: OrganizationReportApplication[] = - this.dialogParams.newApplications.map((app) => ({ - applicationName: app.applicationName, - isCritical: this.selectedApplications().has(app.applicationName), - reviewedDate: newReviewDate, - })); - // Save the application review dates and critical markings - this.dataService - .saveApplicationReviewStatus(updatedApplications) + this.dataService.criticalApplicationAtRiskCipherIds$ .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap((updatedState) => { - // After initial save is complete, created the assigned tasks - // for at risk passwords - const updatedStateApplicationData = updatedState?.data?.applicationData || []; - // Manual enrich for type matching - // TODO Consolidate in model updates - const manualEnrichedApplications = - updatedState?.data?.reportData.map( - (application): ApplicationHealthReportDetailEnriched => ({ - ...application, - isMarkedAsCritical: updatedStateApplicationData.some( - (a) => a.applicationName == application.applicationName && a.isCritical, - ), - }), - ) || []; + takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule + take(1), // Handle unsubscribe for one off operation + switchMap((criticalApplicationAtRiskCipherIds) => { return from( - this.accessIntelligenceSecurityTasksService.assignTasks( + this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications( this.dialogParams.organizationId, - manualEnrichedApplications, + criticalApplicationAtRiskCipherIds, ), ); }), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 794df90da53..7b7ca8c42da 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -4,7 +4,7 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { debounceTime, EMPTY, map, switchMap } from "rxjs"; +import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { @@ -23,7 +23,6 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; import { RiskInsightsTabType } from "../models/risk-insights.models"; import { AppTableRowScrollableComponent } from "../shared/app-table-row-scrollable.component"; import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; @@ -42,7 +41,6 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks SharedModule, AppTableRowScrollableComponent, ], - providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class CriticalApplicationsComponent implements OnInit { private destroyRef = inject(DestroyRef); @@ -58,13 +56,13 @@ export class CriticalApplicationsComponent implements OnInit { constructor( protected activatedRoute: ActivatedRoute, - protected router: Router, - protected toastService: ToastService, protected dataService: RiskInsightsDataService, protected criticalAppsService: CriticalAppsService, - protected reportService: RiskInsightsReportService, protected i18nService: I18nService, - private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + protected reportService: RiskInsightsReportService, + protected router: Router, + private securityTasksService: AccessIntelligenceSecurityTasksService, + protected toastService: ToastService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) @@ -131,10 +129,35 @@ export class CriticalApplicationsComponent implements OnInit { }; async requestPasswordChange() { - await this.accessIntelligenceSecurityTasksService.assignTasks( - this.organizationId, - this.dataSource.data, - ); + this.dataService.criticalApplicationAtRiskCipherIds$ + .pipe( + takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule + take(1), // Handle unsubscribe for one off operation + switchMap((cipherIds) => { + return from( + this.securityTasksService.requestPasswordChangeForCriticalApplications( + this.organizationId, + cipherIds, + ), + ); + }), + ) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, + }); } showAppAtRiskMembers = async (applicationName: string) => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts deleted file mode 100644 index 6f108a46029..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/models/activity.models.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const RenderMode = { - noCriticalApps: "noCriticalApps", - criticalAppsWithAtRiskAppsAndNoTasks: "criticalAppsWithAtRiskAppsAndNoTasks", - criticalAppsWithAtRiskAppsAndTasks: "criticalAppsWithAtRiskAppsAndTasks", -} as const; - -export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index fad2afb6e38..4b7d51af174 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -87,7 +87,7 @@ @if (isRiskInsightsActivityTabFeatureEnabled) { - + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 3bc968dabc1..5a5efa8225d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -78,7 +78,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { appsCount: number = 0; - private organizationId: OrganizationId = "" as OrganizationId; + protected organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index 22f8ea55f51..f6fb41cdbb0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -1,14 +1,9 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { - AllActivitiesService, - ApplicationHealthReportDetailEnriched, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; -import { ToastService } from "@bitwarden/components"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -16,18 +11,14 @@ import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; - const defaultAdminTaskServiceSpy = mock(); - const allActivitiesServiceSpy = mock(); - const toastServiceSpy = mock(); - const i18nServiceSpy = mock(); + const defaultAdminTaskServiceMock = mock(); + const securityTasksApiServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( - allActivitiesServiceSpy, - defaultAdminTaskServiceSpy, - toastServiceSpy, - i18nServiceSpy, + defaultAdminTaskServiceMock, + securityTasksApiServiceMock, ); }); @@ -36,104 +27,48 @@ describe("AccessIntelligenceSecurityTasksService", () => { }); describe("assignTasks", () => { - it("should call requestPasswordChange and setTaskCreatedCount", async () => { + it("should call requestPasswordChangeForCriticalApplications and setTaskCreatedCount", async () => { + // Set up test data const organizationId = "org-1" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid1"], - } as ApplicationHealthReportDetailEnriched, - ]; - const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); - await service.assignTasks(organizationId, apps); - expect(spy).toHaveBeenCalledWith(organizationId, apps); - expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2); + const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId]; + const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications"); + + // Call the method + await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds); + + // Verify that the method was called with correct parameters + expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds); }); }); - describe("requestPasswordChange", () => { + describe("requestPasswordChangeForCriticalApplications", () => { it("should create tasks for distinct cipher ids and show success toast", async () => { + // Set up test data const organizationId = "org-2" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 2, - atRiskCipherIds: ["cid1", "cid2"], - } as ApplicationHealthReportDetailEnriched, - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid2"], - } as ApplicationHealthReportDetailEnriched, - ]; - defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); - i18nServiceSpy.t.mockImplementation((key) => key); + const mockCipherIds = ["cid1" as CipherId, "cid2" as CipherId]; + defaultAdminTaskServiceMock.bulkCreateTasks.mockResolvedValue(undefined); + const spy = jest.spyOn(service, "requestPasswordChangeForCriticalApplications"); - const result = await service.requestPasswordChange(organizationId, apps); + // Call the method + await service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds); - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ + // Verify that bulkCreateTasks was called with distinct cipher ids + expect(defaultAdminTaskServiceMock.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ { cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential }, { cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential }, ]); - expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ - message: "notifiedMembers", - variant: "success", - title: "success", - }); - expect(result).toBe(2); + // Verify that the method was called with correct parameters + expect(spy).toHaveBeenCalledWith(organizationId, mockCipherIds); }); - it("should show error toast and return 0 if bulkCreateTasks throws", async () => { + it("should handle error if defaultAdminTaskService errors", async () => { const organizationId = "org-3" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 1, - atRiskCipherIds: ["cid3"], - } as ApplicationHealthReportDetailEnriched, - ]; - defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); - i18nServiceSpy.t.mockImplementation((key) => key); + const mockCipherIds = ["cid3" as CipherId]; + defaultAdminTaskServiceMock.bulkCreateTasks.mockRejectedValue(new Error("API fail error")); - const result = await service.requestPasswordChange(organizationId, apps); - - expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ - message: "unexpectedError", - variant: "error", - title: "error", - }); - expect(result).toBe(0); - }); - - it("should not create any tasks if no apps have atRiskPasswordCount > 0", async () => { - const organizationId = "org-4" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: true, - atRiskPasswordCount: 0, - atRiskCipherIds: ["cid4"], - } as ApplicationHealthReportDetailEnriched, - ]; - const result = await service.requestPasswordChange(organizationId, apps); - - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); - expect(result).toBe(0); - }); - - it("should not create any tasks for non-critical apps", async () => { - const organizationId = "org-5" as OrganizationId; - const apps = [ - { - isMarkedAsCritical: false, - atRiskPasswordCount: 2, - atRiskCipherIds: ["cid5", "cid6"], - } as ApplicationHealthReportDetailEnriched, - ]; - const result = await service.requestPasswordChange(organizationId, apps); - - expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); - expect(result).toBe(0); + await expect( + service.requestPasswordChangeForCriticalApplications(organizationId, mockCipherIds), + ).rejects.toThrow("API fail error"); }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 4d7a41007eb..688ab039ca9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -1,64 +1,63 @@ -import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; -import { - AllActivitiesService, - ApplicationHealthReportDetailEnriched, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; -import { ToastService } from "@bitwarden/components"; +import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; -@Injectable() +/** + * Service for managing security tasks related to Access Intelligence features + */ export class AccessIntelligenceSecurityTasksService { + private _tasksSubject$ = new BehaviorSubject([]); + tasks$ = this._tasksSubject$.asObservable(); + constructor( - private allActivitiesService: AllActivitiesService, private adminTaskService: DefaultAdminTaskService, - private toastService: ToastService, - private i18nService: I18nService, + private securityTasksApiService: SecurityTasksApiService, ) {} - async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) { - const taskCount = await this.requestPasswordChange(organizationId, apps); - this.allActivitiesService.setTaskCreatedCount(taskCount); + + /** + * Gets security task metrics for the given organization + * + * @param organizationId The organization ID + * @returns Metrics about security tasks such as a count of completed and total tasks + */ + getTaskMetrics(organizationId: OrganizationId) { + return this.securityTasksApiService.getTaskMetrics(organizationId); } - // TODO: this method is shared between here and critical-applications.component.ts - async requestPasswordChange( + /** + * Loads security tasks for the given organization and updates the internal tasks subject + * + * @param organizationId The organization ID + */ + async loadTasks(organizationId: OrganizationId): Promise { + // Loads the tasks to update the service + const tasks = await this.securityTasksApiService.getAllTasks(organizationId); + this._tasksSubject$.next(tasks); + } + + /** + * Bulk assigns password change tasks for critical applications with at-risk passwords + * + * @param organizationId The organization ID + * @param criticalApplicationIds IDs of critical applications with at-risk passwords + */ + async requestPasswordChangeForCriticalApplications( organizationId: OrganizationId, - apps: ApplicationHealthReportDetailEnriched[], - ): Promise { - // Only create tasks for CRITICAL applications with at-risk passwords - const cipherIds = apps - .filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0) - .flatMap((app) => app.atRiskCipherIds); - - const distinctCipherIds = Array.from(new Set(cipherIds)); - + criticalApplicationIds: CipherId[], + ) { + const distinctCipherIds = Array.from(new Set(criticalApplicationIds)); const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ - cipherId: cipherId as CipherId, + cipherId, type: SecurityTaskType.UpdateAtRiskCredential, })); - try { - await this.adminTaskService.bulkCreateTasks(organizationId, tasks); - this.toastService.showToast({ - message: this.i18nService.t("notifiedMembers"), - variant: "success", - title: this.i18nService.t("success"), - }); - - return tasks.length; - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), - }); - } - - return 0; + await this.adminTaskService.bulkCreateTasks(organizationId, tasks); + // Reload tasks after creation + await this.loadTasks(organizationId); } } From 19626d1b3eece71ed6fa59c46484780ee2bd81d4 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 7 Nov 2025 15:58:39 -0500 Subject: [PATCH 58/58] [PM-26363] Add one time setup dialog for auto confirm (#17104) * add one time setup dialog for auto confirm * add one time setup dialog for auto confirm * fix copy, padding, cleanup observable logic * cleanup * cleanup * refactor * clean up * more cleanup * Fix deleted files This reverts commit 7c18a5e5123f90e74fad3f766a74b9d13bec234b. --- ...-confirm-edit-policy-dialog.component.html | 8 +- ...to-confirm-edit-policy-dialog.component.ts | 20 ++++- .../organizations/policies/index.ts | 3 + .../auto-confirm-policy.component.html | 6 +- apps/web/src/app/core/core.module.ts | 32 +++++++- .../vault/individual-vault/vault.component.ts | 81 +++++++++++++++++++ apps/web/src/locales/en/messages.json | 14 ++-- .../models/auto-confirm-state.model.ts | 2 +- .../services/policy/default-policy.service.ts | 2 + libs/state/src/core/state-definitions.ts | 2 +- 10 files changed, 151 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html index b85f79f6038..4d1db65034d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html @@ -38,11 +38,11 @@
    @let showBadge = firstTimeDialog(); @if (showBadge) { - {{ "availableNow" | i18n }} + {{ "availableNow" | i18n }} } - {{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }} - @if (!firstTimeDialog) { + {{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }} + @if (!showBadge) { {{ policy.name | i18n }} @@ -64,7 +64,7 @@ type="submit" > @let autoConfirmEnabled = autoConfirmEnabled$ | async; - @let managePoliciesOnly = managePolicies$ | async; + @let managePoliciesOnly = managePoliciesOnly$ | async; @if (autoConfirmEnabled || managePoliciesOnly) { {{ "save" | i18n }} } @else { diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts index 179dda5a5f4..bdc664e208e 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -22,6 +22,7 @@ import { tap, } from "rxjs"; +import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -85,7 +86,10 @@ export class AutoConfirmPolicyDialogComponent switchMap((userId) => this.policyService.policies$(userId)), map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false), ); - protected managePolicies$: Observable = this.accountService.activeAccount$.pipe( + // Users with manage policies custom permission should not see the dialog's second step since + // they do not have permission to configure the setting. This will only allow them to configure + // the policy. + protected managePoliciesOnly$: Observable = this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => this.organizationService.organizations$(userId)), getById(this.data.organizationId), @@ -116,6 +120,7 @@ export class AutoConfirmPolicyDialogComponent private organizationService: OrganizationService, private policyService: PolicyService, private router: Router, + private autoConfirmService: AutomaticUserConfirmationService, ) { super( data, @@ -161,7 +166,7 @@ export class AutoConfirmPolicyDialogComponent } private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable { - return this.managePolicies$.pipe( + return this.managePoliciesOnly$.pipe( map((managePoliciesOnly) => { const submitSteps = [ { @@ -206,6 +211,17 @@ export class AutoConfirmPolicyDialogComponent autoConfirmRequest, ); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const currentAutoConfirmState = await firstValueFrom( + this.autoConfirmService.configuration$(userId), + ); + + await this.autoConfirmService.upsert(userId, { + ...currentAutoConfirmState, + showSetupDialog: false, + }); + this.toastService.showToast({ variant: "success", message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)), diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index 624e5132faf..3042be240f7 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -2,3 +2,6 @@ export { PoliciesComponent } from "./policies.component"; export { ossPolicyEditRegister } from "./policy-edit-register"; export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; export { POLICY_EDIT_REGISTER } from "./policy-register-token"; +export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component"; +export { AutoConfirmPolicy } from "./policy-edit-definitions"; +export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index c6a62ab2641..54f166b662e 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -47,12 +47,12 @@
      -
    1. 1. {{ "autoConfirmStep1" | i18n }}
    2. +
    3. 1. {{ "autoConfirmExtension1" | i18n }}
    4. - 2. {{ "autoConfirmStep2a" | i18n }} + 2. {{ "autoConfirmExtension2" | i18n }} - {{ "autoConfirmStep2b" | i18n }} + {{ "autoConfirmExtension3" | i18n }}
    diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 72a563a77a3..bf741132b00 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -9,6 +9,10 @@ import { DefaultCollectionAdminService, OrganizationUserApiService, CollectionService, + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, + OrganizationUserService, + DefaultOrganizationUserService, } from "@bitwarden/admin-console/common"; import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service"; import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; @@ -44,7 +48,10 @@ import { } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + InternalOrganizationServiceAbstraction, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService, @@ -338,6 +345,29 @@ const safeProviders: SafeProvider[] = [ OrganizationService, ], }), + safeProvider({ + provide: OrganizationUserService, + useClass: DefaultOrganizationUserService, + deps: [ + KeyServiceAbstraction, + EncryptService, + OrganizationUserApiService, + AccountService, + I18nServiceAbstraction, + ], + }), + safeProvider({ + provide: AutomaticUserConfirmationService, + useClass: DefaultAutomaticUserConfirmationService, + deps: [ + ConfigService, + ApiService, + OrganizationUserService, + StateProvider, + InternalOrganizationServiceAbstraction, + OrganizationUserApiService, + ], + }), safeProvider({ provide: SdkLoadService, useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService, 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 7bdd290336d..4c23119f1eb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -9,6 +9,7 @@ import { lastValueFrom, Observable, Subject, + zip, } from "rxjs"; import { concatMap, @@ -25,6 +26,7 @@ import { } from "rxjs/operators"; import { + AutomaticUserConfirmationService, CollectionData, CollectionDetailsResponse, CollectionService, @@ -54,7 +56,9 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -102,6 +106,11 @@ import { getNestedCollectionTree, getFlatCollectionTree, } from "../../admin-console/organizations/collections"; +import { + AutoConfirmPolicy, + AutoConfirmPolicyDialogComponent, + PolicyEditDialogResult, +} from "../../admin-console/organizations/policies"; import { CollectionDialogAction, CollectionDialogTabType, @@ -213,6 +222,8 @@ export class VaultComponent implements OnInit, OnDestr private destroy$ = new Subject(); private vaultItemDialogRef?: DialogRef | undefined; + private autoConfirmDialogRef?: DialogRef | undefined; + protected showAddCipherBtn: boolean = false; organizations$ = this.accountService.activeAccount$ @@ -328,6 +339,8 @@ export class VaultComponent implements OnInit, OnDestr private policyService: PolicyService, private unifiedUpgradePromptService: UnifiedUpgradePromptService, private premiumUpgradePromptService: PremiumUpgradePromptService, + private autoConfirmService: AutomaticUserConfirmationService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -629,6 +642,8 @@ export class VaultComponent implements OnInit, OnDestr }, ); void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); + + this.setupAutoConfirm(); } ngOnDestroy() { @@ -1547,6 +1562,72 @@ export class VaultComponent implements OnInit, OnDestr const cipherView = await this.cipherService.decrypt(_cipher, activeUserId); return cipherView.login?.password; } + + private async openAutoConfirmFeatureDialog(organization: Organization) { + if (this.autoConfirmDialogRef) { + return; + } + + this.autoConfirmDialogRef = AutoConfirmPolicyDialogComponent.open(this.dialogService, { + data: { + policy: new AutoConfirmPolicy(), + organizationId: organization.id, + firstTimeDialog: true, + }, + }); + + await lastValueFrom(this.autoConfirmDialogRef.closed); + this.autoConfirmDialogRef = undefined; + } + + private setupAutoConfirm() { + // if the policy is enabled, then the user may only belong to one organization at most. + const organization$ = this.organizations$.pipe(map((organizations) => organizations[0])); + + const featureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm); + + const autoConfirmState$ = this.userId$.pipe( + switchMap((userId) => this.autoConfirmService.configuration$(userId)), + ); + + const policyEnabled$ = combineLatest([ + this.userId$.pipe( + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm && p.enabled)), + ), + organization$, + ]).pipe( + map( + ([policy, organization]) => (policy && policy.organizationId === organization?.id) ?? false, + ), + ); + + zip([organization$, featureFlag$, autoConfirmState$, policyEnabled$, this.userId$]) + .pipe( + first(), + switchMap(async ([organization, flagEnabled, autoConfirmState, policyEnabled, userId]) => { + const showDialog = + flagEnabled && + !policyEnabled && + autoConfirmState.showSetupDialog && + !!organization && + (organization.canManageUsers || organization.canManagePolicies); + + if (showDialog) { + await this.openAutoConfirmFeatureDialog(organization); + + await this.autoConfirmService.upsert(userId, { + ...autoConfirmState, + showSetupDialog: false, + }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe({ + error: (err: unknown) => this.logService.error("Failed to update auto-confirm state", err), + }); + } } /** diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 56332e5ac50..5c712c98e0d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5832,16 +5832,16 @@ "howToTurnOnAutoConfirm": { "message": "How to turn on automatic user confirmation" }, - "autoConfirmStep1": { - "message": "Open your Bitwarden extension." + "autoConfirmExtension1": { + "message": "Open your Bitwarden extension" }, - "autoConfirmStep2a": { + "autoConfirmExtension2": { "message": "Select", - "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'" + "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on'" }, - "autoConfirmStep2b": { - "message": " Turn on.", - "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'" + "autoConfirmExtension3": { + "message": " Turn on", + "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on'" }, "autoConfirmExtensionOpened": { "message": "Successfully opened the Bitwarden browser extension. You can now activate the automatic user confirmation setting." diff --git a/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts index c69db69746c..fd3cfa2f590 100644 --- a/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts +++ b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts @@ -16,6 +16,6 @@ export const AUTO_CONFIRM_STATE = UserKeyDefinition.record( "autoConfirm", { deserializer: (autoConfirmState) => autoConfirmState, - clearOn: ["logout"], + clearOn: [], }, ); diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index 1107e88e796..b9d7655195b 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -285,6 +285,8 @@ export class DefaultPolicyService implements PolicyService { case PolicyType.RemoveUnlockWithPin: // Remove Unlock with PIN policy return false; + case PolicyType.AutoConfirm: + return false; case PolicyType.OrganizationDataOwnership: // organization data ownership policy applies to everyone except admins and owners return organization.isAdmin; diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 42d7f5aaaf8..7b1d75b2985 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -36,7 +36,7 @@ export const DELETE_MANAGED_USER_WARNING = new StateDefinition( web: "disk-local", }, ); -export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk"); +export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk", { web: "disk-local" }); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk");
{{ credential.name }}{{ credential.name }} diff --git a/apps/web/src/connectors/duo-redirect.ts b/apps/web/src/connectors/duo-redirect.ts index ae8f84715db..842bd8c0064 100644 --- a/apps/web/src/connectors/duo-redirect.ts +++ b/apps/web/src/connectors/duo-redirect.ts @@ -123,7 +123,7 @@ function displayHandoffMessage(client: string) { ? localeService.t("thisWindowWillCloseIn5Seconds") : localeService.t("youMayCloseThisWindow"); - h1.className = "tw-font-semibold"; + h1.className = "tw-font-medium"; p.className = "tw-mb-4"; content.appendChild(h1); diff --git a/apps/web/src/connectors/webauthn-fallback.html b/apps/web/src/connectors/webauthn-fallback.html index 43da5b1a485..ef85ce6f351 100644 --- a/apps/web/src/connectors/webauthn-fallback.html +++ b/apps/web/src/connectors/webauthn-fallback.html @@ -115,7 +115,7 @@ diff --git a/apps/web/src/connectors/webauthn-mobile.html b/apps/web/src/connectors/webauthn-mobile.html index 06df8b012ab..0551d176eab 100644 --- a/apps/web/src/connectors/webauthn-mobile.html +++ b/apps/web/src/connectors/webauthn-mobile.html @@ -24,7 +24,7 @@ diff --git a/apps/web/src/connectors/webauthn.html b/apps/web/src/connectors/webauthn.html index 27f143f90d3..358e589b68f 100644 --- a/apps/web/src/connectors/webauthn.html +++ b/apps/web/src/connectors/webauthn.html @@ -9,7 +9,7 @@ diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.html b/libs/angular/src/auth/device-management/device-management-item-group.component.html index b6a3ea2d8f8..68081f20199 100644 --- a/libs/angular/src/auth/device-management/device-management-item-group.component.html +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.html @@ -22,7 +22,7 @@
- {{ "firstLogin" | i18n }}: + {{ "firstLogin" | i18n }}: {{ device.firstLogin | date: "medium" }}
@@ -52,7 +52,7 @@ }
- {{ "firstLogin" | i18n }}: + {{ "firstLogin" | i18n }}: {{ device.firstLogin | date: "medium" }}
diff --git a/libs/angular/src/auth/environment-selector/environment-selector.component.html b/libs/angular/src/auth/environment-selector/environment-selector.component.html index f6484ea1e5f..72d7355c399 100644 --- a/libs/angular/src/auth/environment-selector/environment-selector.component.html +++ b/libs/angular/src/auth/environment-selector/environment-selector.component.html @@ -38,7 +38,7 @@
{{ "accessing" | i18n }}: - {{ "important" | i18n }} + {{ "important" | i18n }} {{ "masterPassImportant" | i18n }} {{ minPasswordLengthMsg }}. diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html index 38dc874cd0f..18a0db30904 100644 --- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html +++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html @@ -23,7 +23,7 @@ {{ "notificationSentDeviceComplete" | i18n }}

-
{{ "fingerprintPhraseHeader" | i18n }}
+
{{ "fingerprintPhraseHeader" | i18n }}
{{ fingerprintPhrase }}
+ diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 9401a88ab76..84e5c33d20d 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -94,7 +94,7 @@ (change)="dataSource.checkAllFilteredUsers($any($event.target).checked)" id="selectAll" /> - - Total: {{ totalCost | currency: "$" }} / + Total: {{ totalCost | currency: "$" }} / {{ getBillingCadenceLabel(activePlans.length > 0 ? activePlans[0] : null) | i18n }} diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html index 99e1c173c2a..62ac981664a 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -36,7 +36,7 @@
- {{ + {{ cardDetails.price.amount | currency: "$" }} diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index bc0ca68c5c3..7d25bca5368 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -22,7 +22,7 @@ @if (price(); as priceValue) {
- {{ + {{ priceValue.amount | currency: "$" }} From 87dceff0c8d28c0ee990c254285f1c7a63f8f0ec Mon Sep 17 00:00:00 2001 From: Maximilian Power Date: Thu, 6 Nov 2025 16:32:51 +0100 Subject: [PATCH 35/58] add CSV download buttons to at risk members and applications drawers (#17172) * add CSV download buttons to at risk members and applications drawers --------- Co-authored-by: Alex <55413326+AlexRubik@users.noreply.github.com> --- .../risk-insights.component.html | 18 +++++ .../risk-insights.component.ts | 70 ++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 15ccd3241e4..2476d79552f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -118,6 +118,15 @@ ) | i18n }} +
{{ "email" | i18n }} @@ -173,6 +182,15 @@ ) | i18n }} +
{{ "application" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index cde5d5c8c66..0bcc7ba8a0d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, EMPTY } from "rxjs"; +import { combineLatest, EMPTY, firstValueFrom } from "rxjs"; import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -13,7 +13,9 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, @@ -23,6 +25,8 @@ import { DrawerHeaderComponent, TabsModule, } from "@bitwarden/components"; +import { ExportHelper } from "@bitwarden/vault-export-core"; +import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { AllActivityComponent } from "./activity/all-activity.component"; @@ -88,6 +92,8 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { private configService: ConfigService, protected dataService: RiskInsightsDataService, protected i18nService: I18nService, + private fileDownloadService: FileDownloadService, + private logService: LogService, ) { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; @@ -207,4 +213,66 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { "import", ]); }; + + /** + * downloads at risk members as CSV + */ + downloadAtRiskMembers = async () => { + try { + const drawerDetails = await firstValueFrom(this.dataService.drawerDetails$); + + // Validate drawer is open and showing the correct drawer type + if ( + !drawerDetails.open || + drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskMembers || + !drawerDetails.atRiskMemberDetails || + drawerDetails.atRiskMemberDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-members"), + blobData: exportToCSV(drawerDetails.atRiskMemberDetails, { + email: this.i18nService.t("email"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk members", error); + } + }; + + /** + * downloads at risk applications as CSV + */ + downloadAtRiskApplications = async () => { + try { + const drawerDetails = await firstValueFrom(this.dataService.drawerDetails$); + + // Validate drawer is open and showing the correct drawer type + if ( + !drawerDetails.open || + drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskApps || + !drawerDetails.atRiskAppDetails || + drawerDetails.atRiskAppDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-applications"), + blobData: exportToCSV(drawerDetails.atRiskAppDetails, { + applicationName: this.i18nService.t("application"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk applications", error); + } + }; } From 1cc08c59680d1efe98f447fa6477e65568cb6d40 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 6 Nov 2025 10:45:36 -0500 Subject: [PATCH 36/58] [PM-26984] Use medium instead of semibold or bold (#17189) --- .../shared/report-card/report-card.component.html | 2 +- .../assign-tasks-view.component.html | 4 ++-- .../review-applications-view.component.html | 8 ++++---- .../empty-state-card.component.html | 10 +++------- .../access-intelligence/risk-insights.component.html | 8 ++++---- .../integration-card/integration-card.component.html | 6 +++--- 6 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index a6ae7a246ac..f0318028e60 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -12,7 +12,7 @@
-

{{ title }}

+

{{ title }}

{{ description }}

@if (requiresPremium) { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html index 875e86ed40b..859bc73905c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html @@ -22,7 +22,7 @@ aria-hidden="true" >
- + {{ atRiskCriticalMembersCount() }} @@ -42,7 +42,7 @@ >
- + {{ criticalApplicationsCount() }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html index 15d8160a55d..244cf2c5931 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html @@ -23,16 +23,16 @@ > -
+ {{ "application" | i18n }} + {{ "atRiskPasswords" | i18n }} + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }}