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 01/32] [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 02/32] 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 03/32] 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 04/32] [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 05/32] [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 06/32] [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 07/32] [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 @@ - + -
{{ 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" /> -