From f816e8031442b80429fd931f7b688cb57db7e619 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:14:46 +0100 Subject: [PATCH 01/15] [AC-3023] Resolve the capitalization here (#11019) * Resolve the capitalization here * Resolve the returned issue on A11y * Fix the storage bug --- .../change-plan-dialog.component.html | 13 ++++- .../change-plan-dialog.component.ts | 56 ++++++++++++++++++- apps/web/src/locales/en/messages.json | 2 +- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 766646003ba..8420916c8e0 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -53,10 +53,19 @@ [class]="'tw-grid-cols-' + selectableProducts.length" >
{ + const card = cardElements[newIndex]; + if ( + !( + card.classList.contains("tw-bg-secondary-100") && + card.classList.contains("tw-text-muted") + ) + ) { + card?.focus(); + } + }, 0); + } + } + + onFocus(index: number) { + this.focusedIndex = index; + this.selectPlan(this.selectableProducts[index]); + } + + isCardDisabled(index: number): boolean { + const card = this.selectableProducts[index]; + return card === (this.currentPlan || this.isCardStateDisabled); + } + + manageSelectableProduct(index: number) { + return index; + } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d1d10dc9676..340acc8efb5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9062,7 +9062,7 @@ "message": "Directory integration" }, "passwordLessSso": { - "message": "PasswordLess SSO" + "message": "Passwordless SSO" }, "accountRecovery": { "message": "Account recovery" From 74ee0315c061507cd4f755ec07ef8c6ed79ba371 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 12:37:58 +0200 Subject: [PATCH 02/15] [deps]: Update @yao-pkg/pkg to v5.14.0 (#11068) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cc733665a9..09cea6cacc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,7 +119,7 @@ "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "@webcomponents/custom-elements": "1.6.0", - "@yao-pkg/pkg": "5.12.1", + "@yao-pkg/pkg": "5.14.0", "autoprefixer": "10.4.20", "babel-loader": "9.1.3", "base64-loader": "1.0.0", @@ -10828,16 +10828,16 @@ "license": "Apache-2.0" }, "node_modules/@yao-pkg/pkg": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.12.1.tgz", - "integrity": "sha512-vqp8Z9o39LDKTpjfeDjJsLf4mi0zS4jkbTTZbptfc/K1KKDU2hosex64TaattPO9NLkibc6EJldmSjVmc63ooA==", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.14.0.tgz", + "integrity": "sha512-34oflUyAOI64a4cc4AF3ckvS8Qqnk/ISvZ1bDBa1/JAYaaFtzAO+RlhPaU+wCHzhk6VXvZwEywJpb+SlVDTgdA==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "7.23.0", "@babel/parser": "7.23.0", "@babel/types": "7.23.0", - "@yao-pkg/pkg-fetch": "3.5.9", + "@yao-pkg/pkg-fetch": "3.5.11", "chalk": "^4.1.2", "fs-extra": "^9.1.0", "globby": "^11.1.0", @@ -10854,9 +10854,9 @@ } }, "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.9", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.9.tgz", - "integrity": "sha512-usMwwqFCd2B7k+V87u6kiTesyDSlw+3LpiuYBWe+UgryvSOk/NXjx3XVCub8hQoi0bCREbdQ6NDBqminyHJJrg==", + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.11.tgz", + "integrity": "sha512-2tQ/1n7BLTptW6lL0pfTCnVMIxls8Jiw0/ClK1J2Fja9z2S2j4uzNL5dwGRqtvPJPn/q9i8X+Y+c4dwnMb+NOA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 365508e31ef..1f73d180e1f 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "@webcomponents/custom-elements": "1.6.0", - "@yao-pkg/pkg": "5.12.1", + "@yao-pkg/pkg": "5.14.0", "autoprefixer": "10.4.20", "babel-loader": "9.1.3", "base64-loader": "1.0.0", From 20d83ab198fe9074455425622fd1f9bd6523e8c5 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 16 Sep 2024 15:16:28 +0200 Subject: [PATCH 03/15] [PM-2718] Set nspasteboard.ConcealedType for clipboard on MacOS (#11025) Enables the use of `exclude_from_clipboard` for macos, introduced in recent version of arboard --- apps/desktop/desktop_native/core/src/clipboard.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/desktop_native/core/src/clipboard.rs b/apps/desktop/desktop_native/core/src/clipboard.rs index cecf2478656..bb3e3a43149 100644 --- a/apps/desktop/desktop_native/core/src/clipboard.rs +++ b/apps/desktop/desktop_native/core/src/clipboard.rs @@ -37,8 +37,14 @@ fn clipboard_set(set: Set, _password: bool) -> Set { } #[cfg(target_os = "macos")] -fn clipboard_set(set: Set, _password: bool) -> Set { - set +fn clipboard_set(set: Set, password: bool) -> Set { + use arboard::SetExtApple; + + if password { + set.exclude_from_history() + } else { + set + } } #[cfg(test)] From c64477034a9bc513b9e4936e1fbc9f40cf63b491 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 16 Sep 2024 08:33:23 -0500 Subject: [PATCH 04/15] set `extensionRefreshEnabled` to be undefined so the "new" button isn't shown until the feature flag is resolved (#11038) --- .../individual-vault/vault-header/vault-header.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 403dbd2f675..44e523abe61 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -49,7 +49,7 @@ export class VaultHeaderComponent implements OnInit { protected All = All; protected CollectionDialogTabType = CollectionDialogTabType; protected CipherType = CipherType; - protected extensionRefreshEnabled = false; + protected extensionRefreshEnabled: boolean; /** * Boolean to determine the loading state of the header. From 62ee447c36d99c44ef2eccf7c55545ecbe5089b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:34:33 -0400 Subject: [PATCH 05/15] [deps] Autofill: Update tldts to v6.1.46 (#11054) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index bf60020b523..b5317794c35 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -80,7 +80,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.41", + "tldts": "6.1.46", "zxcvbn": "4.4.2" } } diff --git a/package-lock.json b/package-lock.json index 09cea6cacc1..f3e8b78fc87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.41", + "tldts": "6.1.46", "utf-8-validate": "6.0.4", "zone.js": "0.13.3", "zxcvbn": "4.4.2" @@ -223,7 +223,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.41", + "tldts": "6.1.46", "zxcvbn": "4.4.2" }, "bin": { @@ -36258,21 +36258,21 @@ } }, "node_modules/tldts": { - "version": "6.1.41", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.41.tgz", - "integrity": "sha512-RNpUkL5fYD2DTQQCdr8QMDp6UL0ThtpXT3q3+qPE05dIT+RK2I3M0VByVbQN1dEhLUGzimivVwxK2By9epLk6w==", + "version": "6.1.46", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.46.tgz", + "integrity": "sha512-fw81lXV2CijkNrZAZvee7wegs+EOlTyIuVl/z4q6OUzZHQ1jGL2xQzKXq9geYf/1tzo9LZQLrkcko2m8HLh+rg==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.41" + "tldts-core": "^6.1.46" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.41", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.41.tgz", - "integrity": "sha512-SkwZgo1ZzMp2ziMBwci5VBnLR9VywCi02jSgMX5TO5kf9fdaBsxZkblLff3NlJNTcH0vfvEsgw2B7jVR556Vgw==", + "version": "6.1.46", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.46.tgz", + "integrity": "sha512-zA3ai/j4aFcmbqTvTONkSBuWs0Q4X4tJxa0gV9sp6kDbq5dAhQDSg0WUkReEm0fBAKAGNj+wPKCCsR8MYOYmwA==", "license": "MIT" }, "node_modules/tmp": { diff --git a/package.json b/package.json index 1f73d180e1f..85ccf459b40 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.41", + "tldts": "6.1.46", "utf-8-validate": "6.0.4", "zone.js": "0.13.3", "zxcvbn": "4.4.2" From b0e0e71974d93b16df9f06fd5daf37e8470219f8 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Mon, 16 Sep 2024 08:35:56 -0500 Subject: [PATCH 06/15] [PM-11517] Improve autofill collection of page details performance (#10816) * Testing out a rework of the performance improvements introduced into extension * Working through improvements * Implementing max_depth methodology for the deepQuery approach used when querying elements * Refactoring implementation * Refactoring implementation * Fixing jest tests * Incorporating documenation within domQueryService * [PM-11519] `browser` global reference triggering an error when sending an extension message * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Removing unnecessary property * [PM-11517] Starting to work through an idea regarding querying without the shadowDom on pages that definitively do not contain a ShadowDOM element * [PM-11419] Adjusting implementation to ensure we clear any active requests when the passkeys setting is modified * [PM-11517] Removing unnecessary comments --- .../content/auto-submit-login.spec.ts | 19 +- .../src/autofill/content/auto-submit-login.ts | 39 ++- .../src/autofill/content/autofill-init.ts | 2 +- .../abstractions/dom-query.service.ts | 11 +- .../autofill-overlay-content.service.ts | 24 +- .../collect-autofill-content.service.spec.ts | 58 ++-- .../collect-autofill-content.service.ts | 281 +++++++----------- .../services/dom-query.service.spec.ts | 86 +++++- .../autofill/services/dom-query.service.ts | 132 ++++++-- .../insert-autofill-content.service.spec.ts | 2 +- .../src/autofill/spec/testing-utils.ts | 2 +- apps/browser/src/autofill/utils/index.spec.ts | 33 ++ apps/browser/src/autofill/utils/index.ts | 26 ++ libs/common/src/autofill/constants/index.ts | 2 + 14 files changed, 437 insertions(+), 280 deletions(-) diff --git a/apps/browser/src/autofill/content/auto-submit-login.spec.ts b/apps/browser/src/autofill/content/auto-submit-login.spec.ts index 98caee3d363..ff1dbd4e945 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.spec.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.spec.ts @@ -5,23 +5,17 @@ import { createAutofillPageDetailsMock, createAutofillScriptMock, } from "../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils"; +import { + flushPromises, + mockQuerySelectorAllDefinedCall, + sendMockExtensionMessage, +} from "../spec/testing-utils"; import { FormFieldElement } from "../types"; let pageDetailsMock: AutofillPageDetails; let fillScriptMock: AutofillScript; let autofillFieldElementByOpidMock: FormFieldElement; -jest.mock("../services/dom-query.service", () => { - const module = jest.requireActual("../services/dom-query.service"); - return { - DomQueryService: class extends module.DomQueryService { - deepQueryElements(element: HTMLElement, queryString: string): T[] { - return Array.from(element.querySelectorAll(queryString)) as T[]; - } - }, - }; -}); jest.mock("../services/collect-autofill-content.service", () => { const module = jest.requireActual("../services/collect-autofill-content.service"); return { @@ -47,6 +41,8 @@ jest.mock("../services/collect-autofill-content.service", () => { jest.mock("../services/insert-autofill-content.service"); describe("AutoSubmitLogin content script", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + beforeEach(() => { jest.useFakeTimers(); setupEnvironmentDefaults(); @@ -60,6 +56,7 @@ describe("AutoSubmitLogin content script", () => { afterAll(() => { jest.clearAllMocks(); + mockQuerySelectorAll.mockRestore(); }); it("ends the auto-submit login workflow if the page does not contain any fields", async () => { diff --git a/apps/browser/src/autofill/content/auto-submit-login.ts b/apps/browser/src/autofill/content/auto-submit-login.ts index ab7f09f804d..e304247a66a 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.ts @@ -10,7 +10,9 @@ import InsertAutofillContentService from "../services/insert-autofill-content.se import { elementIsInputElement, getSubmitButtonKeywordsSet, + nodeIsButtonElement, nodeIsFormElement, + nodeIsTypeSubmitElement, sendExtensionMessage, } from "../utils"; @@ -189,13 +191,21 @@ import { element: HTMLElement, lastFieldIsPasswordInput = false, ): boolean { - const genericSubmitElement = querySubmitButtonElement(element, "[type='submit']"); + const genericSubmitElement = querySubmitButtonElement( + element, + "[type='submit']", + (node: Node) => nodeIsTypeSubmitElement(node), + ); if (genericSubmitElement) { clickSubmitElement(genericSubmitElement, lastFieldIsPasswordInput); return true; } - const buttonElement = querySubmitButtonElement(element, "button, [type='button']"); + const buttonElement = querySubmitButtonElement( + element, + "button, [type='button']", + (node: Node) => nodeIsButtonElement(node), + ); if (buttonElement) { clickSubmitElement(buttonElement, lastFieldIsPasswordInput); return true; @@ -210,11 +220,17 @@ import { * * @param element - The element to query for submit buttons * @param selector - The selector to query for submit buttons + * @param treeWalkerFilter - The callback used to filter treeWalker results */ - function querySubmitButtonElement(element: HTMLElement, selector: string) { - const submitButtonElements = domQueryService.deepQueryElements( + function querySubmitButtonElement( + element: HTMLElement, + selector: string, + treeWalkerFilter: CallableFunction, + ) { + const submitButtonElements = domQueryService.query( element, selector, + treeWalkerFilter, ); for (let index = 0; index < submitButtonElements.length; index++) { const submitElement = submitButtonElements[index]; @@ -272,20 +288,11 @@ import { * Gets all form elements on the page. */ function getAutofillFormElements(): HTMLFormElement[] { - const formElements: HTMLFormElement[] = []; - domQueryService.queryAllTreeWalkerNodes( + return domQueryService.query( globalContext.document.documentElement, - (node: Node) => { - if (nodeIsFormElement(node)) { - formElements.push(node); - return true; - } - - return false; - }, + "form", + (node: Node) => nodeIsFormElement(node), ); - - return formElements; } /** diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index c0cbac3ae67..e901000dbb9 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -38,7 +38,7 @@ class AutofillInit implements AutofillInitInterface { * @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined. */ constructor( - private domQueryService: DomQueryService, + domQueryService: DomQueryService, private autofillOverlayContentService?: AutofillOverlayContentService, private autofillInlineMenuContentService?: AutofillInlineMenuContentService, private overlayNotificationsContentService?: OverlayNotificationsContentService, diff --git a/apps/browser/src/autofill/services/abstractions/dom-query.service.ts b/apps/browser/src/autofill/services/abstractions/dom-query.service.ts index 8b0b2f5dbdd..3e0242bc74b 100644 --- a/apps/browser/src/autofill/services/abstractions/dom-query.service.ts +++ b/apps/browser/src/autofill/services/abstractions/dom-query.service.ts @@ -1,12 +1,11 @@ export interface DomQueryService { - deepQueryElements( + query( root: Document | ShadowRoot | Element, queryString: string, + treeWalkerFilter: CallableFunction, mutationObserver?: MutationObserver, + forceDeepQueryAttempt?: boolean, ): T[]; - queryAllTreeWalkerNodes( - rootNode: Node, - filterCallback: CallableFunction, - mutationObserver?: MutationObserver, - ): Node[]; + checkPageContainsShadowDom(): void; + pageContainsShadowDomElements(): boolean; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 23a4fc27000..2e85fa22819 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -32,6 +32,8 @@ import { elementIsFillableFormField, elementIsSelectElement, getAttributeBoolean, + nodeIsButtonElement, + nodeIsTypeSubmitElement, sendExtensionMessage, throttle, } from "../utils"; @@ -508,12 +510,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param element - The element to find the submit button within. */ private findSubmitButton(element: HTMLElement): HTMLElement | null { - const genericSubmitElement = this.querySubmitButtonElement(element, "[type='submit']"); + const genericSubmitElement = this.querySubmitButtonElement( + element, + "[type='submit']", + (node: Node) => nodeIsTypeSubmitElement(node), + ); if (genericSubmitElement) { return genericSubmitElement; } - const submitButtonElement = this.querySubmitButtonElement(element, "button, [type='button']"); + const submitButtonElement = this.querySubmitButtonElement( + element, + "button, [type='button']", + (node: Node) => nodeIsButtonElement(node), + ); if (submitButtonElement) { return submitButtonElement; } @@ -524,11 +534,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * * @param element - The element to query for a submit button. * @param selector - The selector to use to query the element for a submit button. + * @param treeWalkerFilter - The tree walker filter to use when querying the element. */ - private querySubmitButtonElement(element: HTMLElement, selector: string) { - const submitButtonElements = this.domQueryService.deepQueryElements( + private querySubmitButtonElement( + element: HTMLElement, + selector: string, + treeWalkerFilter: CallableFunction, + ) { + const submitButtonElements = this.domQueryService.query( element, selector, + treeWalkerFilter, ); for (let index = 0; index < submitButtonElements.length; index++) { const submitElement = submitButtonElements[index]; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 97b231a1dac..441a2ea17a8 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -17,6 +17,14 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic import DomElementVisibilityService from "./dom-element-visibility.service"; import { DomQueryService } from "./dom-query.service"; +jest.mock("../utils", () => { + const utils = jest.requireActual("../utils"); + return { + ...utils, + debounce: jest.fn((fn) => fn), + }; +}); + const mockLoginForm = `
@@ -29,6 +37,7 @@ const mockLoginForm = ` const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve)); describe("CollectAutofillContentService", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); const domElementVisibilityService = new DomElementVisibilityService(); const inlineMenuFieldQualificationService = mock(); const domQueryService = new DomQueryService(); @@ -38,7 +47,6 @@ describe("CollectAutofillContentService", () => { ); let collectAutofillContentService: CollectAutofillContentService; const mockIntersectionObserver = mock(); - const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(() => { globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100)); @@ -55,6 +63,7 @@ describe("CollectAutofillContentService", () => { afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); + jest.clearAllTimers(); document.body.innerHTML = ""; }); @@ -2001,41 +2010,6 @@ describe("CollectAutofillContentService", () => { }); }); - describe("getShadowRoot", () => { - beforeEach(() => { - // eslint-disable-next-line - // @ts-ignore - globalThis.chrome.dom = { - openOrClosedShadowRoot: jest.fn(), - }; - }); - - it("returns null if the passed node is not an HTMLElement instance", () => { - const textNode = document.createTextNode("Hello, world!"); - const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode); - - expect(shadowRoot).toEqual(null); - }); - - it("returns an open shadow root if the passed node has a shadowDOM element", () => { - const element = document.createElement("div"); - element.attachShadow({ mode: "open" }); - - const shadowRoot = collectAutofillContentService["getShadowRoot"](element); - - expect(shadowRoot).toBeInstanceOf(ShadowRoot); - }); - - it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => { - const element = document.createElement("div"); - collectAutofillContentService["getShadowRoot"](element); - - // eslint-disable-next-line - // @ts-ignore - expect(chrome.dom.openOrClosedShadowRoot).toBeCalled(); - }); - }); - describe("setupMutationObserver", () => { it("sets up a mutation observer and observes the document element", () => { jest.spyOn(MutationObserver.prototype, "observe"); @@ -2048,6 +2022,12 @@ describe("CollectAutofillContentService", () => { }); describe("handleMutationObserverMutation", () => { + const waitForAllMutationsToComplete = async () => { + await waitForIdleCallback(); + await waitForIdleCallback(); + await waitForIdleCallback(); + }; + it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", async () => { const form = document.createElement("form"); document.body.appendChild(form); @@ -2071,7 +2051,7 @@ describe("CollectAutofillContentService", () => { jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); @@ -2115,7 +2095,7 @@ describe("CollectAutofillContentService", () => { target: document.body, }, ]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0); expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); @@ -2140,7 +2120,7 @@ describe("CollectAutofillContentService", () => { jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false); expect(collectAutofillContentService["noFieldsFound"]).toEqual(true); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index efacafbe88e..7da1d6aa6ba 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -20,6 +20,7 @@ import { getPropertyOrAttribute, requestIdleCallbackPolyfill, cancelIdleCallbackPolyfill, + debounce, } from "../utils"; import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service"; @@ -57,7 +58,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ "image", "file", ]); - private useTreeWalkerStrategyFlagSet = true; constructor( private domElementVisibilityService: DomElementVisibilityService, @@ -69,11 +69,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ inputQuery += `:not([type="${type}"])`; } this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`; - - // void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then( - // (useTreeWalkerStrategyFlag) => - // (this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result), - // ); } get autofillFormElements(): AutofillFormElements { @@ -297,13 +292,12 @@ export class CollectAutofillContentService implements CollectAutofillContentServ ): FormFieldElement[] { let formFieldElements = previouslyFoundFormFieldElements; if (!formFieldElements) { - formFieldElements = this.useTreeWalkerStrategyFlagSet - ? this.queryTreeWalkerForAutofillFormFieldElements() - : this.domQueryService.deepQueryElements( - document, - this.formFieldQueryString, - this.mutationObserver, - ); + formFieldElements = this.domQueryService.query( + globalThis.document.documentElement, + this.formFieldQueryString, + (node: Node) => this.isNodeFormFieldElement(node), + this.mutationObserver, + ); } if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { @@ -836,17 +830,32 @@ export class CollectAutofillContentService implements CollectAutofillContentServ formElements: HTMLFormElement[]; formFieldElements: FormFieldElement[]; } { - if (this.useTreeWalkerStrategyFlagSet) { - return this.queryTreeWalkerForAutofillFormAndFieldElements(); - } - - const queriedElements = this.domQueryService.deepQueryElements( - document, - `form, ${this.formFieldQueryString}`, - this.mutationObserver, - ); const formElements: HTMLFormElement[] = []; const formFieldElements: FormFieldElement[] = []; + + const queriedElements = this.domQueryService.query( + globalThis.document.documentElement, + `form, ${this.formFieldQueryString}`, + (node: Node) => { + if (nodeIsFormElement(node)) { + formElements.push(node); + return true; + } + + if (this.isNodeFormFieldElement(node)) { + formFieldElements.push(node as FormFieldElement); + return true; + } + + return false; + }, + this.mutationObserver, + ); + + if (formElements.length || formFieldElements.length) { + return { formElements, formFieldElements }; + } + for (let index = 0; index < queriedElements.length; index++) { const element = queriedElements[index]; if (elementIsFormElement(element)) { @@ -891,34 +900,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute; } - /** - * Attempts to get the ShadowRoot of the passed node. If support for the - * extension based openOrClosedShadowRoot API is available, it will be used. - * Will return null if the node is not an HTMLElement or if the node has - * child nodes. - * - * @param {Node} node - */ - private getShadowRoot(node: Node): ShadowRoot | null { - if (!nodeIsElement(node)) { - return null; - } - - if (node.shadowRoot) { - return node.shadowRoot; - } - - if ((chrome as any).dom?.openOrClosedShadowRoot) { - try { - return (chrome as any).dom.openOrClosedShadowRoot(node); - } catch (error) { - return null; - } - } - - return (node as any).openOrClosedShadowRoot; - } - /** * Sets up a mutation observer on the body of the document. Observes changes to * DOM elements to ensure we have an updated set of autofill field data. @@ -948,7 +929,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } if (!this.mutationsQueue.length) { - requestIdleCallbackPolyfill(this.processMutations, { timeout: 500 }); + requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 }); } this.mutationsQueue.push(mutations); }; @@ -979,41 +960,62 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * within an idle callback to help with performance and prevent excessive updates. */ private processMutations = () => { - for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) { - this.processMutationRecord(this.mutationsQueue[queueIndex]); + const queueLength = this.mutationsQueue.length; + + if (!this.domQueryService.pageContainsShadowDomElements()) { + this.domQueryService.checkPageContainsShadowDom(); } - if (this.domRecentlyMutated) { - this.updateAutofillElementsAfterMutation(); + for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) { + const mutations = this.mutationsQueue[queueIndex]; + const processMutationRecords = () => { + this.processMutationRecords(mutations); + + if (queueIndex === queueLength - 1 && this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + }; + + requestIdleCallbackPolyfill(processMutationRecords, { timeout: 500 }); } this.mutationsQueue = []; }; /** - * Processes a mutation record and updates the autofill elements if necessary. + * Processes all mutation records encountered by the mutation observer. * * @param mutations - The mutation record to process */ - private processMutationRecord(mutations: MutationRecord[]) { + private processMutationRecords(mutations: MutationRecord[]) { for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) { - const mutation = mutations[mutationIndex]; - if ( - mutation.type === "childList" && - (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || - this.isAutofillElementNodeMutated(mutation.addedNodes)) - ) { - this.domRecentlyMutated = true; - if (this.autofillOverlayContentService) { - this.autofillOverlayContentService.pageDetailsUpdateRequired = true; - } - this.noFieldsFound = false; - continue; - } + const mutation: MutationRecord = mutations[mutationIndex]; + const processMutationRecord = () => this.processMutationRecord(mutation); + requestIdleCallbackPolyfill(processMutationRecord, { timeout: 500 }); + } + } - if (mutation.type === "attributes") { - this.handleAutofillElementAttributeMutation(mutation); + /** + * Processes a single mutation record and updates the autofill elements if necessary. + * @param mutation + * @private + */ + private processMutationRecord(mutation: MutationRecord) { + if ( + mutation.type === "childList" && + (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || + this.isAutofillElementNodeMutated(mutation.addedNodes)) + ) { + this.domRecentlyMutated = true; + if (this.autofillOverlayContentService) { + this.autofillOverlayContentService.pageDetailsUpdateRequired = true; } + this.noFieldsFound = false; + return; + } + + if (mutation.type === "attributes") { + this.handleAutofillElementAttributeMutation(mutation); } } @@ -1036,20 +1038,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ continue; } - if ( - !this.useTreeWalkerStrategyFlagSet && - (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) - ) { + if (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) { mutatedElements.push(node as HTMLElement); } - const autofillElements = this.useTreeWalkerStrategyFlagSet - ? this.queryTreeWalkerForMutatedElements(node) - : this.domQueryService.deepQueryElements( - node, - `form, ${this.formFieldQueryString}`, - this.mutationObserver, - ); + const autofillElements = this.domQueryService.query( + node, + `form, ${this.formFieldQueryString}`, + (walkerNode: Node) => + nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode), + this.mutationObserver, + true, + ); + if (autofillElements.length) { mutatedElements = mutatedElements.concat(autofillElements); } @@ -1083,19 +1084,20 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) { for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { const node = mutatedElements[elementIndex]; - if ( - !this.isNodeFormFieldElement(node) || - this.autofillFieldElements.get(node as ElementWithOpId) - ) { - continue; - } + const buildAutofillFieldItem = () => { + if ( + !this.isNodeFormFieldElement(node) || + this.autofillFieldElements.get(node as ElementWithOpId) + ) { + return; + } - requestIdleCallbackPolyfill( // We are setting this item to a -1 index because we do not know its position in the DOM. // This value should be updated with the next call to collect page details. - () => void this.buildAutofillFieldItem(node as ElementWithOpId, -1), - { timeout: 1000 }, - ); + void this.buildAutofillFieldItem(node as ElementWithOpId, -1); + }; + + requestIdleCallbackPolyfill(buildAutofillFieldItem, { timeout: 1000 }); } } @@ -1367,6 +1369,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } } + /** + * Validates whether a password field is within the document. + */ + isPasswordFieldWithinDocument(): boolean { + return ( + this.domQueryService.query( + globalThis.document.documentElement, + `input[type="password"]`, + (node: Node) => nodeIsInputElement(node) && node.type === "password", + )?.length > 0 + ); + } + /** * Destroys the CollectAutofillContentService. Clears all * timeouts and disconnects the mutation observer. @@ -1378,84 +1393,4 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver?.disconnect(); this.intersectionObserver?.disconnect(); } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForAutofillFormAndFieldElements(): { - formElements: HTMLFormElement[]; - formFieldElements: FormFieldElement[]; - } { - const formElements: HTMLFormElement[] = []; - const formFieldElements: FormFieldElement[] = []; - this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => { - if (nodeIsFormElement(node)) { - formElements.push(node); - return true; - } - - if (this.isNodeFormFieldElement(node)) { - formFieldElements.push(node as FormFieldElement); - return true; - } - - return false; - }, - this.mutationObserver, - ); - - return { formElements, formFieldElements }; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => this.isNodeFormFieldElement(node), - this.mutationObserver, - ) as FormFieldElement[]; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - * - * @param node - The node to query - */ - private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - node, - (walkerNode: Node) => - nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode), - this.mutationObserver, - ) as HTMLElement[]; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForPasswordElements(): HTMLElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => nodeIsInputElement(node) && node.type === "password", - ) as HTMLElement[]; - } - - /** - * This is a temporary method to maintain a fallback strategy for the tree walker API - * - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - isPasswordFieldWithinDocument(): boolean { - if (this.useTreeWalkerStrategyFlagSet) { - return Boolean(this.queryTreeWalkerForPasswordElements()?.length); - } - - return Boolean( - this.domQueryService.deepQueryElements(document, `input[type="password"]`)?.length, - ); - } } diff --git a/apps/browser/src/autofill/services/dom-query.service.spec.ts b/apps/browser/src/autofill/services/dom-query.service.spec.ts index 22212333fc8..8071a464f44 100644 --- a/apps/browser/src/autofill/services/dom-query.service.spec.ts +++ b/apps/browser/src/autofill/services/dom-query.service.spec.ts @@ -1,21 +1,60 @@ -import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; +import { flushPromises, mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; import { DomQueryService } from "./dom-query.service"; +jest.mock("../utils", () => { + const actualUtils = jest.requireActual("../utils"); + return { + ...actualUtils, + sendExtensionMessage: jest.fn((command, options) => { + if (command === "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag") { + return Promise.resolve({ result: false }); + } + + return chrome.runtime.sendMessage(Object.assign({ command }, options)); + }), + }; +}); + describe("DomQueryService", () => { + const originalDocumentReadyState = document.readyState; let domQueryService: DomQueryService; let mutationObserver: MutationObserver; const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); - beforeEach(() => { - domQueryService = new DomQueryService(); + beforeEach(async () => { mutationObserver = new MutationObserver(() => {}); + domQueryService = new DomQueryService(); + await flushPromises(); + }); + + afterEach(() => { + Object.defineProperty(document, "readyState", { + value: originalDocumentReadyState, + writable: true, + }); }); afterAll(() => { mockQuerySelectorAll.mockRestore(); }); + it("checks the page content for shadow DOM elements after the page has completed loading", async () => { + Object.defineProperty(document, "readyState", { + value: "loading", + writable: true, + }); + jest.spyOn(globalThis, "addEventListener"); + + const domQueryService = new DomQueryService(); + await flushPromises(); + + expect(globalThis.addEventListener).toHaveBeenCalledWith( + "load", + domQueryService["checkPageContainsShadowDom"], + ); + }); + describe("deepQueryElements", () => { it("queries form field elements that are nested within a ShadowDOM", () => { const root = document.createElement("div"); @@ -26,9 +65,10 @@ describe("DomQueryService", () => { form.appendChild(input); shadowRoot.appendChild(form); - const formFieldElements = domQueryService.deepQueryElements( + const formFieldElements = domQueryService.query( shadowRoot, "input", + (element: Element) => element.tagName === "INPUT", mutationObserver, ); @@ -36,6 +76,7 @@ describe("DomQueryService", () => { }); it("queries form field elements that are nested within multiple ShadowDOM elements", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); @@ -47,18 +88,50 @@ describe("DomQueryService", () => { shadowRoot2.appendChild(form); shadowRoot1.appendChild(root2); - const formFieldElements = domQueryService.deepQueryElements( + const formFieldElements = domQueryService.query( shadowRoot1, "input", + (element: Element) => element.tagName === "INPUT", mutationObserver, ); expect(formFieldElements).toStrictEqual([input]); }); + + it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => { + domQueryService["pageContainsShadowDom"] = true; + const root = document.createElement("div"); + const shadowRoot1 = root.attachShadow({ mode: "open" }); + const root2 = document.createElement("div"); + const shadowRoot2 = root2.attachShadow({ mode: "open" }); + const root3 = document.createElement("div"); + const shadowRoot3 = root3.attachShadow({ mode: "open" }); + const root4 = document.createElement("div"); + const shadowRoot4 = root4.attachShadow({ mode: "open" }); + const root5 = document.createElement("div"); + const shadowRoot5 = root5.attachShadow({ mode: "open" }); + const form = document.createElement("form"); + const input = document.createElement("input"); + input.type = "text"; + form.appendChild(input); + shadowRoot5.appendChild(form); + shadowRoot4.appendChild(root5); + shadowRoot3.appendChild(root4); + shadowRoot2.appendChild(root3); + shadowRoot1.appendChild(root2); + const treeWalkerCallback = jest + .fn() + .mockImplementation(() => (element: Element) => element.tagName === "INPUT"); + + domQueryService.query(shadowRoot1, "input", treeWalkerCallback, mutationObserver); + + expect(treeWalkerCallback).toHaveBeenCalled(); + }); }); describe("queryAllTreeWalkerNodes", () => { it("queries form field elements that are nested within multiple ShadowDOM elements", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); @@ -70,8 +143,9 @@ describe("DomQueryService", () => { shadowRoot2.appendChild(form); shadowRoot1.appendChild(root2); - const formFieldElements = domQueryService.queryAllTreeWalkerNodes( + const formFieldElements = domQueryService.query( shadowRoot1, + "input", (element: Element) => element.tagName === "INPUT", mutationObserver, ); diff --git a/apps/browser/src/autofill/services/dom-query.service.ts b/apps/browser/src/autofill/services/dom-query.service.ts index 0d766ea3ba0..570027b2d12 100644 --- a/apps/browser/src/autofill/services/dom-query.service.ts +++ b/apps/browser/src/autofill/services/dom-query.service.ts @@ -1,8 +1,77 @@ -import { nodeIsElement } from "../utils"; +import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants"; + +import { nodeIsElement, sendExtensionMessage } from "../utils"; import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service"; export class DomQueryService implements DomQueryServiceInterface { + private pageContainsShadowDom: boolean; + private useTreeWalkerStrategyFlagSet = true; + + constructor() { + void this.init(); + } + + /** + * Sets up a query that will trigger a deepQuery of the DOM, querying all elements that match the given query string. + * If the deepQuery fails or reaches a max recursion depth, it will fall back to a treeWalker query. + * + * @param root - The root element to start the query from + * @param queryString - The query string to match elements against + * @param treeWalkerFilter - The filter callback to use for the treeWalker query + * @param mutationObserver - The MutationObserver to use for observing shadow roots + * @param forceDeepQueryAttempt - Whether to force a deep query attempt + */ + query( + root: Document | ShadowRoot | Element, + queryString: string, + treeWalkerFilter: CallableFunction, + mutationObserver?: MutationObserver, + forceDeepQueryAttempt?: boolean, + ): T[] { + if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) { + return this.queryAllTreeWalkerNodes(root, treeWalkerFilter, mutationObserver); + } + + try { + return this.deepQueryElements(root, queryString, mutationObserver); + } catch { + return this.queryAllTreeWalkerNodes(root, treeWalkerFilter, mutationObserver); + } + } + + /** + * Checks if the page contains any shadow DOM elements. + */ + checkPageContainsShadowDom = (): void => { + this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0; + }; + + /** + * Determines whether to use the treeWalker strategy for querying the DOM. + */ + pageContainsShadowDomElements(): boolean { + return this.useTreeWalkerStrategyFlagSet || this.pageContainsShadowDom; + } + + /** + * Initializes the DomQueryService, checking for the presence of shadow DOM elements on the page. + */ + private async init() { + const useTreeWalkerStrategyFlag = await sendExtensionMessage( + "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", + ); + if (useTreeWalkerStrategyFlag && typeof useTreeWalkerStrategyFlag.result === "boolean") { + this.useTreeWalkerStrategyFlagSet = useTreeWalkerStrategyFlag.result; + } + + if (globalThis.document.readyState === "complete") { + this.checkPageContainsShadowDom(); + return; + } + globalThis.addEventListener(EVENTS.LOAD, this.checkPageContainsShadowDom); + } + /** * Queries all elements in the DOM that match the given query string. * Also, recursively queries all shadow roots for the element. @@ -11,16 +80,25 @@ export class DomQueryService implements DomQueryServiceInterface { * @param queryString - The query string to match elements against * @param mutationObserver - The MutationObserver to use for observing shadow roots */ - deepQueryElements( + private deepQueryElements( root: Document | ShadowRoot | Element, queryString: string, mutationObserver?: MutationObserver, ): T[] { let elements = this.queryElements(root, queryString); - const shadowRoots = this.recursivelyQueryShadowRoots(root, mutationObserver); + + const shadowRoots = this.recursivelyQueryShadowRoots(root); for (let index = 0; index < shadowRoots.length; index++) { const shadowRoot = shadowRoots[index]; elements = elements.concat(this.queryElements(shadowRoot, queryString)); + + if (mutationObserver) { + mutationObserver.observe(shadowRoot, { + attributes: true, + childList: true, + subtree: true, + }); + } } return elements; @@ -46,23 +124,24 @@ export class DomQueryService implements DomQueryServiceInterface { * `isObservingShadowRoot` parameter is set to true. * * @param root - The root element to start the query from - * @param mutationObserver - The MutationObserver to use for observing shadow roots + * @param depth - The depth of the recursion */ private recursivelyQueryShadowRoots( root: Document | ShadowRoot | Element, - mutationObserver?: MutationObserver, + depth: number = 0, ): ShadowRoot[] { + if (!this.pageContainsShadowDom) { + return []; + } + + if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) { + throw new Error("Max recursion depth reached"); + } + let shadowRoots = this.queryShadowRoots(root); for (let index = 0; index < shadowRoots.length; index++) { const shadowRoot = shadowRoots[index]; - shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot)); - if (mutationObserver) { - mutationObserver.observe(shadowRoot, { - attributes: true, - childList: true, - subtree: true, - }); - } + shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot, depth + 1)); } return shadowRoots; @@ -72,14 +151,23 @@ export class DomQueryService implements DomQueryServiceInterface { * Queries any immediate shadow roots found within the given root element. * * @param root - The root element to start the query from + * @param returnSingleShadowRoot - Whether to return a single shadow root or an array of shadow roots */ - private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] { + private queryShadowRoots( + root: Document | ShadowRoot | Element, + returnSingleShadowRoot = false, + ): ShadowRoot[] { const shadowRoots: ShadowRoot[] = []; const potentialShadowRoots = root.querySelectorAll(":defined"); for (let index = 0; index < potentialShadowRoots.length; index++) { const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]); - if (shadowRoot) { - shadowRoots.push(shadowRoot); + if (!shadowRoot) { + continue; + } + + shadowRoots.push(shadowRoot); + if (returnSingleShadowRoot) { + break; } } @@ -121,12 +209,12 @@ export class DomQueryService implements DomQueryServiceInterface { * @param filterCallback * @param mutationObserver */ - queryAllTreeWalkerNodes( + private queryAllTreeWalkerNodes( rootNode: Node, filterCallback: CallableFunction, mutationObserver?: MutationObserver, - ): Node[] { - const treeWalkerQueryResults: Node[] = []; + ): T[] { + const treeWalkerQueryResults: T[] = []; this.buildTreeWalkerNodesQueryResults( rootNode, @@ -147,9 +235,9 @@ export class DomQueryService implements DomQueryServiceInterface { * @param filterCallback * @param mutationObserver */ - private buildTreeWalkerNodesQueryResults( + private buildTreeWalkerNodesQueryResults( rootNode: Node, - treeWalkerQueryResults: Node[], + treeWalkerQueryResults: T[], filterCallback: CallableFunction, mutationObserver?: MutationObserver, ) { @@ -158,7 +246,7 @@ export class DomQueryService implements DomQueryServiceInterface { while (currentNode) { if (filterCallback(currentNode)) { - treeWalkerQueryResults.push(currentNode); + treeWalkerQueryResults.push(currentNode as T); } const nodeShadowRoot = this.getShadowRoot(currentNode); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index e5e21c4b021..3541656f4e6 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -68,6 +68,7 @@ function setMockWindowLocation({ } describe("InsertAutofillContentService", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); const inlineMenuFieldQualificationService = mock(); const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(); @@ -82,7 +83,6 @@ describe("InsertAutofillContentService", () => { ); let insertAutofillContentService: InsertAutofillContentService; let fillScript: AutofillScript; - const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(() => { document.body.innerHTML = mockLoginForm; diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 4bbcd72dda2..a4e7a42eb21 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -176,7 +176,7 @@ export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.Web export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; - document.querySelectorAll = function (selector: string) { + globalThis.document.querySelectorAll = function (selector: string) { return originalDocumentQuerySelectorAll.call( document, selector === ":defined" ? "*" : selector, diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 36d22ed0cd3..62a707860c3 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -10,6 +10,7 @@ import { setElementStyles, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, + debounce, } from "./index"; describe("buildSvgDomElement", () => { @@ -211,3 +212,35 @@ describe("setupAutofillInitDisconnectAction", () => { expect(window.bitwardenAutofillInit).toBeUndefined(); }); }); + +describe("debounce", () => { + const debouncedFunction = jest.fn(); + const debounced = debounce(debouncedFunction, 100); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it("does not call the method until the delay is complete", () => { + debounced(); + jest.advanceTimersByTime(50); + expect(debouncedFunction).not.toHaveBeenCalled(); + }); + + it("calls the method a single time when the debounce is triggered multiple times", () => { + debounced(); + debounced(); + debounced(); + jest.advanceTimersByTime(100); + + expect(debouncedFunction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 7c18e7fd127..98c0a97ac56 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -311,6 +311,18 @@ export function nodeIsFormElement(node: Node): node is HTMLFormElement { return nodeIsElement(node) && elementIsFormElement(node); } +export function nodeIsTypeSubmitElement(node: Node): node is HTMLElement { + return nodeIsElement(node) && getPropertyOrAttribute(node as HTMLElement, "type") === "submit"; +} + +export function nodeIsButtonElement(node: Node): node is HTMLButtonElement { + return ( + nodeIsElement(node) && + (elementIsInstanceOf(node, "button") || + getPropertyOrAttribute(node as HTMLElement, "type") === "button") + ); +} + /** * Returns a boolean representing the attribute value of an element. * @@ -361,6 +373,20 @@ export function throttle(callback: (_args: any) => any, limit: number) { }; } +/** + * Debounces a callback function to run after a delay of `delay` milliseconds. + * + * @param callback - The callback function to debounce. + * @param delay - The time in milliseconds to debounce the callback. + */ +export function debounce(callback: (_args: any) => any, delay: number) { + let timeout: NodeJS.Timeout; + return function (...args: unknown[]) { + globalThis.clearTimeout(timeout); + timeout = globalThis.setTimeout(() => callback.apply(this, args), delay); + }; +} + /** * Gathers and normalizes keywords from a potential submit button element. Used * to verify if the element submits a login or change password form. diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 4b0ea53ad0d..15005691d29 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -107,3 +107,5 @@ export const ExtensionCommand = { export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand]; export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute + +export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4; From 16651214ac623fe7e4bddc3707b82599ee7419fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:47:56 -0400 Subject: [PATCH 07/15] [deps] Platform: Update @types/node to v20.16.5 (#11058) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../native-messaging-test-runner/package-lock.json | 8 ++++---- apps/desktop/native-messaging-test-runner/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 3343b4b6024..c9bc710b537 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -18,7 +18,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "20.16.4", + "@types/node": "20.16.5", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" } @@ -106,9 +106,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.16.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.4.tgz", - "integrity": "sha512-ioyQ1zK9aGEomJ45zz8S8IdzElyxhvP1RVWnPrXDf6wFaUb+kk1tEcVVJkF7RPGM0VWI7cp5U57oCPIn5iN1qg==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 595cb9c2e0e..549ffe78fbd 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -23,7 +23,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "20.16.4", + "@types/node": "20.16.5", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" }, diff --git a/package-lock.json b/package-lock.json index f3e8b78fc87..1c74fa0cc96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,7 +108,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "20.16.4", + "@types/node": "20.16.5", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/node-ipc": "9.2.3", @@ -9401,9 +9401,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.16.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.4.tgz", - "integrity": "sha512-ioyQ1zK9aGEomJ45zz8S8IdzElyxhvP1RVWnPrXDf6wFaUb+kk1tEcVVJkF7RPGM0VWI7cp5U57oCPIn5iN1qg==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 85ccf459b40..03fcb04b926 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "20.16.4", + "@types/node": "20.16.5", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/node-ipc": "9.2.3", From 63b6c01a655e1a70c6d56376165f58f5093a287c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:58:04 -0400 Subject: [PATCH 08/15] [deps] Autofill: Update wait-on to v8.0.1 (#11055) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c74fa0cc96..00a0e890bf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,7 +179,7 @@ "typescript": "5.1.6", "url": "0.11.3", "util": "0.12.5", - "wait-on": "8.0.0", + "wait-on": "8.0.1", "webpack": "5.94.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", @@ -38266,13 +38266,13 @@ } }, "node_modules/wait-on": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.0.tgz", - "integrity": "sha512-fNE5SXinLr2Bt7cJvjvLg2PcXfqznlqRvtE3f8AqYdRZ9BhE+XpsCp1mwQbRoO7s1q7uhAuCw0Ro3mG/KdZjEw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.1.tgz", + "integrity": "sha512-1wWQOyR2LVVtaqrcIL2+OM+x7bkpmzVROa0Nf6FryXkS+er5Sa1kzFGjzZRqLnHa3n1rACFLeTwUqE1ETL9Mig==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.7.4", + "axios": "^1.7.7", "joi": "^17.13.3", "lodash": "^4.17.21", "minimist": "^1.2.8", diff --git a/package.json b/package.json index 03fcb04b926..3783af2c61d 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "typescript": "5.1.6", "url": "0.11.3", "util": "0.12.5", - "wait-on": "8.0.0", + "wait-on": "8.0.1", "webpack": "5.94.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", From 096a2563bb6eb4af8aad95320212c34d9b5c0aba Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:23:10 -0400 Subject: [PATCH 09/15] Piped submit text to i18n (#11078) --- .../adjust-payment-dialog-v2.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog-v2.component.html b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog-v2.component.html index fb1ff3e0d00..e41d3d961cd 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog-v2.component.html +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog-v2.component.html @@ -9,7 +9,7 @@ +
+

+ {{ "accountSecurity" | i18n }} +

+
+ +
+ +
+
+

{{ "unlockMethods" | i18n }}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

{{ "sessionTimeoutHeader" | i18n }}

+
+ + + {{ + "vaultTimeoutPolicyWithActionInEffect" + | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) + }} + + + {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} + + + {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} + + + + +
+ + +
+ +
+
+
+

{{ "otherOptions" | i18n }}

+
+ + + + + +
+
+
diff --git a/apps/browser/src/auth/popup/settings/account-security-v1.component.ts b/apps/browser/src/auth/popup/settings/account-security-v1.component.ts new file mode 100644 index 00000000000..4975ba5f7a2 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/account-security-v1.component.ts @@ -0,0 +1,501 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + combineLatest, + concatMap, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + pairwise, + startWith, + Subject, + switchMap, + takeUntil, +} from "rxjs"; + +import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/types/vault-timeout.type"; +import { DialogService } from "@bitwarden/components"; + +import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { enableAccountSwitching } from "../../../platform/flags"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { SetPinComponent } from "../components/set-pin.component"; + +import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; + +@Component({ + selector: "auth-account-security", + templateUrl: "account-security-v1.component.html", +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class AccountSecurityComponent implements OnInit, OnDestroy { + protected readonly VaultTimeoutAction = VaultTimeoutAction; + + availableVaultTimeoutActions: VaultTimeoutAction[] = []; + vaultTimeoutOptions: VaultTimeoutOption[]; + vaultTimeoutPolicyCallout: Observable<{ + timeout: { hours: number; minutes: number }; + action: VaultTimeoutAction; + }>; + supportsBiometric: boolean; + showChangeMasterPass = true; + accountSwitcherEnabled = false; + + form = this.formBuilder.group({ + vaultTimeout: [null as VaultTimeout | null], + vaultTimeoutAction: [VaultTimeoutAction.Lock], + pin: [null as boolean | null], + biometric: false, + enableAutoBiometricsPrompt: true, + }); + + private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + private destroy$ = new Subject(); + + constructor( + private accountService: AccountService, + private pinService: PinServiceAbstraction, + private policyService: PolicyService, + private formBuilder: FormBuilder, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private vaultTimeoutService: VaultTimeoutService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + public messagingService: MessagingService, + private environmentService: EnvironmentService, + private cryptoService: CryptoService, + private stateService: StateService, + private userVerificationService: UserVerificationService, + private dialogService: DialogService, + private changeDetectorRef: ChangeDetectorRef, + private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, + ) { + this.accountSwitcherEnabled = enableAccountSwitching(); + } + + async ngOnInit() { + const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout); + this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe( + filter((policy) => policy != null), + map((policy) => { + let timeout; + if (policy.data?.minutes) { + timeout = { + hours: Math.floor(policy.data?.minutes / 60), + minutes: policy.data?.minutes % 60, + }; + } + return { timeout: timeout, action: policy.data?.action }; + }), + ); + + const showOnLocked = + !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); + + this.vaultTimeoutOptions = [ + { name: this.i18nService.t("immediately"), value: 0 }, + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + ]; + + if (showOnLocked) { + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("onRestart"), + value: VaultTimeoutStringType.OnRestart, + }); + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("never"), + value: VaultTimeoutStringType.Never, + }); + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + let timeout = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(activeAccount.id), + ); + if (timeout === VaultTimeoutStringType.OnLocked && !showOnLocked) { + timeout = VaultTimeoutStringType.OnRestart; + } + + const initialValues = { + vaultTimeout: timeout, + vaultTimeoutAction: await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ), + pin: await this.pinService.isPinSet(activeAccount.id), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + enableAutoBiometricsPrompt: await firstValueFrom( + this.biometricStateService.promptAutomatically$, + ), + }; + this.form.patchValue(initialValues, { emitEvent: false }); + + this.supportsBiometric = await this.biometricsService.supportsBiometric(); + this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword(); + + this.form.controls.vaultTimeout.valueChanges + .pipe( + startWith(initialValues.vaultTimeout), // emit to init pairwise + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveVaultTimeout(previousValue, newValue); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.vaultTimeoutAction.valueChanges + .pipe( + startWith(initialValues.vaultTimeoutAction), // emit to init pairwise + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveVaultTimeoutAction(previousValue, newValue); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.pin.valueChanges + .pipe( + concatMap(async (value) => { + await this.updatePin(value); + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.biometric.valueChanges + .pipe( + distinctUntilChanged(), + concatMap(async (enabled) => { + await this.updateBiometric(enabled); + if (enabled) { + this.form.controls.enableAutoBiometricsPrompt.enable(); + } else { + this.form.controls.enableAutoBiometricsPrompt.disable(); + } + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.refreshTimeoutSettings$ + .pipe( + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([availableActions, action]) => { + this.availableVaultTimeoutActions = availableActions; + this.form.controls.vaultTimeoutAction.setValue(action, { emitEvent: false }); + // NOTE: The UI doesn't properly update without detect changes. + // I've even tried using an async pipe, but it still doesn't work. I'm not sure why. + // Using an async pipe means that we can't call `detectChanges` AFTER the data has change + // meaning that we are forced to use regular class variables instead of observables. + this.changeDetectorRef.detectChanges(); + }); + + this.refreshTimeoutSettings$ + .pipe( + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + maximumVaultTimeoutPolicy, + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([availableActions, policy]) => { + if (policy?.data?.action || availableActions.length <= 1) { + this.form.controls.vaultTimeoutAction.disable({ emitEvent: false }); + } else { + this.form.controls.vaultTimeoutAction.enable({ emitEvent: false }); + } + }); + } + + async saveVaultTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) { + if (newValue === VaultTimeoutStringType.Never) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + + if (!confirmed) { + this.form.controls.vaultTimeout.setValue(previousValue, { emitEvent: false }); + return; + } + } + + // The minTimeoutError does not apply to browser because it supports Immediately + // So only check for the policyError + if (this.form.controls.vaultTimeout.hasError("policyError")) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("vaultTimeoutTooLarge"), + ); + return; + } + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + const vaultTimeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + activeAccount.id, + newValue, + vaultTimeoutAction, + ); + if (newValue === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } + + async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) { + if (newValue === VaultTimeoutAction.LogOut) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + this.form.controls.vaultTimeoutAction.setValue(previousValue, { + emitEvent: false, + }); + return; + } + } + + if (this.form.controls.vaultTimeout.hasError("policyError")) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("vaultTimeoutTooLarge"), + ); + return; + } + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + activeAccount.id, + this.form.value.vaultTimeout, + newValue, + ); + this.refreshTimeoutSettings$.next(); + } + + async updatePin(value: boolean) { + if (value) { + const dialogRef = SetPinComponent.open(this.dialogService); + + if (dialogRef == null) { + this.form.controls.pin.setValue(false, { emitEvent: false }); + return; + } + + const userHasPinSet = await firstValueFrom(dialogRef.closed); + this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false }); + } else { + await this.vaultTimeoutSettingsService.clear(); + } + } + + async updateBiometric(enabled: boolean) { + if (enabled && this.supportsBiometric) { + let granted; + try { + granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] }); + } catch (e) { + // eslint-disable-next-line + console.error(e); + + if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) { + await this.dialogService.openSimpleDialog({ + title: { key: "nativeMessaginPermissionSidebarTitle" }, + content: { key: "nativeMessaginPermissionSidebarDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "info", + }); + + this.form.controls.biometric.setValue(false); + return; + } + } + + if (!granted) { + await this.dialogService.openSimpleDialog({ + title: { key: "nativeMessaginPermissionErrorTitle" }, + content: { key: "nativeMessaginPermissionErrorDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + this.form.controls.biometric.setValue(false); + return; + } + + const awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); + const awaitDesktopDialogClosed = firstValueFrom(awaitDesktopDialogRef.closed); + + await this.cryptoService.refreshAdditionalKeys(); + + await Promise.race([ + awaitDesktopDialogClosed.then(async (result) => { + if (result !== true) { + this.form.controls.biometric.setValue(false); + } + }), + this.biometricsService + .authenticateBiometric() + .then((result) => { + this.form.controls.biometric.setValue(result); + if (!result) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorEnableBiometricTitle"), + this.i18nService.t("errorEnableBiometricDesc"), + ); + } + }) + .catch((e) => { + // Handle connection errors + this.form.controls.biometric.setValue(false); + + const error = BiometricErrors[e.message as BiometricErrorTypes]; + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.dialogService.openSimpleDialog({ + title: { key: error.title }, + content: { key: error.description }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + }) + .finally(() => { + awaitDesktopDialogRef.close(true); + }), + ]); + } else { + await this.biometricStateService.setBiometricUnlockEnabled(false); + await this.biometricStateService.setFingerprintValidated(false); + } + } + + async updateAutoBiometricsPrompt() { + await this.biometricStateService.setPromptAutomatically( + this.form.value.enableAutoBiometricsPrompt, + ); + } + + async changePassword() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "continueToWebApp" }, + content: { key: "changeMasterPasswordOnWebConfirmation" }, + type: "info", + acceptButtonText: { key: "continue" }, + }); + if (confirmed) { + const env = await firstValueFrom(this.environmentService.environment$); + await BrowserApi.createNewTab(env.getWebVaultUrl()); + } + } + + async twoStep() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "twoStepLogin" }, + content: { key: "twoStepLoginConfirmation" }, + type: "info", + }); + if (confirmed) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + BrowserApi.createNewTab("https://bitwarden.com/help/setup-two-step-login/"); + } + } + + async fingerprint() { + const fingerprint = await this.cryptoService.getFingerprint( + await this.stateService.getUserId(), + ); + + const dialogRef = FingerprintDialogComponent.open(this.dialogService, { + fingerprint, + }); + + return firstValueFrom(dialogRef.closed); + } + + async lock() { + await this.vaultTimeoutService.lock(); + } + + async logOut() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + type: "info", + }); + + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + if (confirmed) { + this.messagingService.send("logout", { userId: userId }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index dff9675743f..af6525daa8a 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -1,140 +1,126 @@ - -
- -
-

- {{ "accountSecurity" | i18n }} -

-
- -
-
-
-
-

{{ "unlockMethods" | i18n }}

-
-
- - -
-
- - -
-
- - -
-
-
-
-

{{ "sessionTimeoutHeader" | i18n }}

-
- - - {{ - "vaultTimeoutPolicyWithActionInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) - }} - - - {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} - - - {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - - - -
- - + {{ + "unlockWithBiometrics" | i18n + }} + + + + {{ + "enableAutoBiometricsPrompt" | i18n + }} + + - - -
- -
-
-
-

{{ "otherOptions" | i18n }}

-
- - - - + + + + + + + + +
{{ "lockNow" | i18n }}
- - -
{{ "logOut" | i18n }}
- - -
+ +
-
+ diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 25401f06f38..8e0acc7d641 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -1,15 +1,15 @@ import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, concatMap, distinctUntilChanged, - filter, firstValueFrom, map, - Observable, pairwise, startWith, Subject, @@ -17,7 +17,8 @@ import { takeUntil, } from "rxjs"; -import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -39,30 +40,67 @@ import { VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/types/vault-timeout.type"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { + CardComponent, + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, + ToastService, +} from "@bitwarden/components"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { enableAccountSwitching } from "../../../platform/flags"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { SetPinComponent } from "../components/set-pin.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; @Component({ - selector: "auth-account-security", templateUrl: "account-security.component.html", + standalone: true, + imports: [ + CardComponent, + CheckboxModule, + CommonModule, + FormFieldModule, + FormsModule, + ReactiveFormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + PopOutComponent, + PopupFooterComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, + VaultTimeoutInputComponent, + ], }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class AccountSecurityComponent implements OnInit, OnDestroy { protected readonly VaultTimeoutAction = VaultTimeoutAction; + showMasterPasswordOnClientRestartOption = true; availableVaultTimeoutActions: VaultTimeoutAction[] = []; - vaultTimeoutOptions: VaultTimeoutOption[]; - vaultTimeoutPolicyCallout: Observable<{ - timeout: { hours: number; minutes: number }; - action: VaultTimeoutAction; - }>; + vaultTimeoutOptions: VaultTimeoutOption[] = []; + hasVaultTimeoutPolicy = false; supportsBiometric: boolean; showChangeMasterPass = true; accountSwitcherEnabled = false; @@ -71,6 +109,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { vaultTimeout: [null as VaultTimeout | null], vaultTimeoutAction: [VaultTimeoutAction.Lock], pin: [null as boolean | null], + pinLockWithMasterPassword: false, biometric: false, enableAutoBiometricsPrompt: true, }); @@ -102,20 +141,12 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } async ngOnInit() { + const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); + this.showMasterPasswordOnClientRestartOption = hasMasterPassword; const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout); - this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe( - filter((policy) => policy != null), - map((policy) => { - let timeout; - if (policy.data?.minutes) { - timeout = { - hours: Math.floor(policy.data?.minutes / 60), - minutes: policy.data?.minutes % 60, - }; - } - return { timeout: timeout, action: policy.data?.action }; - }), - ); + if ((await firstValueFrom(this.policyService.get$(PolicyType.MaximumVaultTimeout))) != null) { + this.hasVaultTimeoutPolicy = true; + } const showOnLocked = !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); @@ -161,6 +192,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), ), pin: await this.pinService.isPinSet(activeAccount.id), + pinLockWithMasterPassword: + (await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL", biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, @@ -185,9 +218,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.form.controls.vaultTimeoutAction.valueChanges .pipe( startWith(initialValues.vaultTimeoutAction), // emit to init pairwise - pairwise(), - concatMap(async ([previousValue, newValue]) => { - await this.saveVaultTimeoutAction(previousValue, newValue); + map(async (value) => { + await this.saveVaultTimeoutAction(value); }), takeUntil(this.destroy$), ) @@ -203,6 +235,22 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.pinLockWithMasterPassword.valueChanges + .pipe( + concatMap(async (value) => { + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const pinKeyEncryptedUserKey = + (await this.pinService.getPinKeyEncryptedUserKeyPersistent(userId)) || + (await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId)); + await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId); + await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); + await this.pinService.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKey, value, userId); + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.form.controls.biometric.valueChanges .pipe( distinctUntilChanged(), @@ -219,6 +267,15 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.enableAutoBiometricsPrompt.valueChanges + .pipe( + concatMap(async (enabled) => { + await this.biometricStateService.setPromptAutomatically(enabled); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.refreshTimeoutSettings$ .pipe( switchMap(() => @@ -272,17 +329,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } } - // The minTimeoutError does not apply to browser because it supports Immediately - // So only check for the policyError - if (this.form.controls.vaultTimeout.hasError("policyError")) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("vaultTimeoutTooLarge"), - }); - return; - } - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const vaultTimeoutAction = await firstValueFrom( @@ -299,8 +345,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } } - async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) { - if (newValue === VaultTimeoutAction.LogOut) { + async saveVaultTimeoutAction(value: VaultTimeoutAction) { + if (value === VaultTimeoutAction.LogOut) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "vaultTimeoutLogOutConfirmationTitle" }, content: { key: "vaultTimeoutLogOutConfirmation" }, @@ -308,7 +354,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }); if (!confirmed) { - this.form.controls.vaultTimeoutAction.setValue(previousValue, { + this.form.controls.vaultTimeoutAction.setValue(VaultTimeoutAction.Lock, { emitEvent: false, }); return; @@ -329,7 +375,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( activeAccount.id, this.form.value.vaultTimeout, - newValue, + value, ); this.refreshTimeoutSettings$.next(); } @@ -343,8 +389,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { return; } + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((account) => account.id)), + ); const userHasPinSet = await firstValueFrom(dialogRef.closed); this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false }); + const requireReprompt = (await this.pinService.getPinLockType(userId)) == "EPHEMERAL"; + this.form.controls.pinLockWithMasterPassword.setValue(requireReprompt, { emitEvent: false }); } else { await this.vaultTimeoutSettingsService.clear(); } @@ -386,77 +437,91 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { return; } - let awaitDesktopDialogRef: DialogRef | undefined; - let biometricsResponseReceived = false; - await this.cryptoService.refreshAdditionalKeys(); - const waitForUserDialogPromise = async () => { - // only show waiting dialog if we have waited for 200 msec to prevent double dialog - // the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong - await new Promise((resolve) => setTimeout(resolve, 200)); - if (biometricsResponseReceived) { + const successful = await this.trySetupBiometrics(); + this.form.controls.biometric.setValue(successful); + if (!successful) { + await this.biometricStateService.setBiometricUnlockEnabled(false); + await this.biometricStateService.setFingerprintValidated(false); + } + } + } + + async trySetupBiometrics(): Promise { + let awaitDesktopDialogRef: DialogRef | undefined; + let biometricsResponseReceived = false; + let setupResult = false; + + const waitForUserDialogPromise = async () => { + // only show waiting dialog if we have waited for 500 msec to prevent double dialog + // the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong + await new Promise((resolve) => setTimeout(resolve, 500)); + if (biometricsResponseReceived) { + return; + } + + awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); + await firstValueFrom(awaitDesktopDialogRef.closed); + if (!biometricsResponseReceived) { + setupResult = false; + } + return; + }; + + const biometricsPromise = async () => { + try { + const result = await this.biometricsService.authenticateBiometric(); + + // prevent duplicate dialog + biometricsResponseReceived = true; + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(result); + } + + if (!result) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorEnableBiometricTitle"), + this.i18nService.t("errorEnableBiometricDesc"), + ); + } + setupResult = true; + } catch (e) { + // prevent duplicate dialog + biometricsResponseReceived = true; + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(true); + } + + if (e.message == "canceled") { + setupResult = false; return; } - awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); - const result = await firstValueFrom(awaitDesktopDialogRef.closed); - if (result !== true) { - this.form.controls.biometric.setValue(false); + const error = BiometricErrors[e.message as BiometricErrorTypes]; + const shouldRetry = await this.dialogService.openSimpleDialog({ + title: { key: error.title }, + content: { key: error.description }, + acceptButtonText: { key: "retry" }, + cancelButtonText: null, + type: "danger", + }); + if (shouldRetry) { + setupResult = await this.trySetupBiometrics(); + } else { + setupResult = false; + return; } - }; - - const biometricsPromise = async () => { - try { - const result = await this.biometricsService.authenticateBiometric(); - - // prevent duplicate dialog - biometricsResponseReceived = true; - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } - - this.form.controls.biometric.setValue(result); - if (!result) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorEnableBiometricTitle"), - message: this.i18nService.t("errorEnableBiometricDesc"), - }); - } - } catch (e) { - // prevent duplicate dialog - biometricsResponseReceived = true; - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } - - this.form.controls.biometric.setValue(false); - - if (e.message == "canceled") { - return; - } - - const error = BiometricErrors[e.message as BiometricErrorTypes]; - await this.dialogService.openSimpleDialog({ - title: { key: error.title }, - content: { key: error.description }, - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - type: "danger", - }); - } finally { - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } + } finally { + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(true); } - }; + } + }; - await Promise.race([waitForUserDialogPromise(), biometricsPromise()]); - } else { - await this.biometricStateService.setBiometricUnlockEnabled(false); - await this.biometricStateService.setFingerprintValidated(false); - } + await Promise.all([waitForUserDialogPromise(), biometricsPromise()]); + return setupResult; } async updateAutoBiometricsPrompt() { @@ -471,6 +536,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { content: { key: "changeMasterPasswordOnWebConfirmation" }, type: "info", acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, }); if (confirmed) { const env = await firstValueFrom(this.environmentService.environment$); @@ -480,9 +546,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { async twoStep() { const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "twoStepLogin" }, - content: { key: "twoStepLoginConfirmation" }, + title: { key: "twoStepLoginConfirmationTitle" }, + content: { key: "twoStepLoginConfirmationContent" }, type: "info", + acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, }); if (confirmed) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 0f6a9d9248d..14f35a78fb3 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -41,6 +41,7 @@ import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; +import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { SsoComponent } from "../auth/popup/sso.component"; import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"; @@ -296,12 +297,11 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "autofill" }, }), - { + ...extensionRefreshSwap(AccountSecurityV1Component, AccountSecurityComponent, { path: "account-security", - component: AccountSecurityComponent, canActivate: [authGuard], data: { state: "account-security" }, - }, + }), ...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, { path: "notifications", canActivate: [authGuard], diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index f14dafacb70..d5777215b19 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -15,7 +15,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; -import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components"; +import { AvatarModule, ButtonModule, FormFieldModule, ToastModule } from "@bitwarden/components"; import { AccountComponent } from "../auth/popup/account-switching/account.component"; import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; @@ -30,6 +30,7 @@ import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; +import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; import { SsoComponent } from "../auth/popup/sso.component"; @@ -98,6 +99,7 @@ import "../platform/popup/locales"; A11yModule, AppRoutingModule, AutofillComponent, + AccountSecurityComponent, ToastModule.forRoot({ maxOpened: 2, autoDismiss: true, @@ -132,6 +134,7 @@ import "../platform/popup/locales"; HeaderComponent, UserVerificationDialogComponent, CurrentAccountComponent, + FormFieldModule, ExtensionAnonLayoutWrapperComponent, ], declarations: [ @@ -171,7 +174,6 @@ import "../platform/popup/locales"; SendListComponent, SendTypeComponent, SetPasswordComponent, - AccountSecurityComponent, SettingsComponent, VaultSettingsComponent, ShareComponent, @@ -183,6 +185,7 @@ import "../platform/popup/locales"; TwoFactorOptionsComponent, UpdateTempPasswordComponent, UserVerificationComponent, + AccountSecurityComponentV1, VaultTimeoutInputComponent, ViewComponent, ViewCustomFieldsComponent, diff --git a/apps/desktop/src/auth/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html index 50e7aca75f3..cadd5340bb2 100644 --- a/apps/desktop/src/auth/components/set-pin.component.html +++ b/apps/desktop/src/auth/components/set-pin.component.html @@ -22,7 +22,7 @@ bitCheckbox formControlName="requireMasterPasswordOnClientRestart" /> - {{ "lockWithMasterPassOnRestart" | i18n }} + {{ "lockWithMasterPassOnRestart1" | i18n }}
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 721faa25675..bd9ad5075b4 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -942,6 +942,9 @@ "vaultTimeout": { "message": "Vault timeout" }, + "vaultTimeout1": { + "message": "Timeout" + }, "vaultTimeoutDesc": { "message": "Choose when your vault will take the vault timeout action." }, @@ -1567,7 +1570,7 @@ "recommendedForSecurity": { "message": "Recommended for security." }, - "lockWithMasterPassOnRestart": { + "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, "deleteAccount": { @@ -2099,8 +2102,8 @@ "minutes": { "message": "Minutes" }, - "vaultTimeoutPolicyInEffect": { - "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "vaultTimeoutPolicyInEffect1": { + "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", "placeholders": { "hours": { "content": "$1", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 340acc8efb5..7b518e4899d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1024,8 +1024,8 @@ "unexpectedError": { "message": "An unexpected error has occurred." }, - "expirationDateError" : { - "message":"Please select an expiration date that is in the future." + "expirationDateError": { + "message": "Please select an expiration date that is in the future." }, "emailAddress": { "message": "Email address" @@ -1033,8 +1033,8 @@ "yourVaultIsLockedV2": { "message": "Your vault is locked" }, - "uuid":{ - "message" : "UUID" + "uuid": { + "message": "UUID" }, "unlock": { "message": "Unlock" @@ -1270,10 +1270,10 @@ "copyUuid": { "message": "Copy UUID" }, - "errorRefreshingAccessToken":{ + "errorRefreshingAccessToken": { "message": "Access Token Refresh Error" }, - "errorRefreshingAccessTokenDesc":{ + "errorRefreshingAccessTokenDesc": { "message": "No refresh token or API keys found. Please try logging out and logging back in." }, "warning": { @@ -3993,6 +3993,9 @@ "vaultTimeout": { "message": "Vault timeout" }, + "vaultTimeout1": { + "message": "Timeout" + }, "vaultTimeoutDesc": { "message": "Choose when your vault will take the vault timeout action." }, @@ -4997,7 +5000,7 @@ "youNeedApprovalFromYourAdminToTrySecretsManager": { "message": "You need approval from your administrator to try Secrets Manager." }, - "smAccessRequestEmailSent" : { + "smAccessRequestEmailSent": { "message": "Access request for secrets manager email sent to admins." }, "requestAccessSMDefaultEmailContent": { @@ -5006,8 +5009,8 @@ "giveMembersAccess": { "message": "Give members access:" }, - "viewAndSelectTheMembers" : { - "message" :"view and select the members you want to give access to Secrets Manager." + "viewAndSelectTheMembers": { + "message": "view and select the members you want to give access to Secrets Manager." }, "openYourOrganizations": { "message": "Open your organization's" @@ -5471,6 +5474,19 @@ } } }, + "vaultTimeoutPolicyInEffect1": { + "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, "vaultTimeoutPolicyWithActionInEffect": { "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", "placeholders": { @@ -5497,9 +5513,6 @@ } } }, - "customVaultTimeout": { - "message": "Custom vault timeout" - }, "vaultTimeoutToLarge": { "message": "Your vault timeout exceeds the restriction set by your organization." }, @@ -5944,10 +5957,10 @@ "selfHostedBaseUrlHint": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" }, - "selfHostedCustomEnvHeader" :{ + "selfHostedCustomEnvHeader": { "message": "For advanced configuration, you can specify the base URL of each service independently." }, - "selfHostedEnvFormInvalid" :{ + "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, "apiUrl": { @@ -7709,7 +7722,7 @@ } } }, - "verificationRequired" : { + "verificationRequired": { "message": "Verification required", "description": "Default title for the user verification dialog." }, @@ -8501,7 +8514,7 @@ "deleteProviderRecoverConfirmDesc": { "message": "You have requested to delete this Provider. Use the button below to confirm." }, - "deleteProviderWarning": { + "deleteProviderWarning": { "message": "Deleting your provider is permanent. It cannot be undone." }, "errorAssigningTargetCollection": { @@ -8514,7 +8527,7 @@ "message": "Integrations & SDKs", "description": "The title for the section that deals with integrations and SDKs." }, - "integrations":{ + "integrations": { "message": "Integrations" }, "integrationsDesc": { @@ -8585,7 +8598,7 @@ }, "createdNewClient": { "message": "Successfully created new client" - }, + }, "noAccess": { "message": "No access" }, @@ -8821,11 +8834,11 @@ "placeholders": { "value": { "content": "$1", - "example":"increments of 100,000" + "example": "increments of 100,000" } } }, - "providerReinstate":{ + "providerReinstate": { "message": " Contact Customer Support to reinstate your subscription." }, "secretPeopleDescription": { diff --git a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html index 9a534d461c4..d6005c970f7 100644 --- a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html +++ b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html @@ -1,8 +1,10 @@ - {{ "yourAccountsFingerprint" | i18n }}: + {{ "yourAccountsFingerprint" | i18n }}: - {{ data.fingerprint.join("-") }} + {{ data.fingerprint.join("-") }} - - {{ "vaultTimeout" | i18n }} +
+ + {{ "vaultTimeout1" | i18n }} - {{ - ((canLockVault$ | async) ? "vaultTimeoutDesc" : "vaultTimeoutLogoutDesc") | i18n - }}
- - {{ "customVaultTimeout" | i18n }} - - {{ "hours" | i18n }} + + + {{ "hours" | i18n }} - - - {{ "minutes" | i18n }} + + + {{ "minutes" | i18n }}
- + + {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} + + {{ "vaultCustomTimeoutMinimum" | i18n }} + + + {{ + "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes + }} +
diff --git a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts index 7c760835603..d42e7d7d15b 100644 --- a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts +++ b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts @@ -55,16 +55,41 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"]; export class VaultTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges { + protected readonly VaultTimeoutAction = VaultTimeoutAction; + get showCustom() { return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE; } - get exceedsMinimumTimout(): boolean { + get exceedsMinimumTimeout(): boolean { return ( !this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES ); } + get exceedsMaximumTimeout(): boolean { + return ( + this.showCustom && + this.customTimeInMinutes() > + this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ); + } + + get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { + // by policy max value + if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { + return this.vaultTimeoutOptions; + } + + return this.vaultTimeoutOptions.filter((option) => { + if (typeof option.value === "number") { + return option.value <= this.vaultTimeoutPolicy.data.minutes; + } + + return false; + }); + } + static CUSTOM_VALUE = -100; static MIN_CUSTOM_MINUTES = 0; @@ -77,6 +102,7 @@ export class VaultTimeoutInputComponent }); @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + vaultTimeoutPolicy: Policy; vaultTimeoutPolicyHours: number; vaultTimeoutPolicyMinutes: number; @@ -207,7 +233,7 @@ export class VaultTimeoutInputComponent return { policyError: true }; } - if (!this.exceedsMinimumTimout) { + if (!this.exceedsMinimumTimeout) { return { minTimeoutError: true }; } From f2142e318ec335c02b284d5fc5f8d0ac4057dc6e Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:15:34 -0400 Subject: [PATCH 13/15] BRE-315 - Update workflow to push to TestFlight on protected branches (#11082) --- .github/workflows/build-desktop.yml | 67 +++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8ac65d257c6..8170f1eef00 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -738,7 +738,7 @@ jobs: $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - "### MacOS GitHub build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + Write-Output "### MacOS GitHub build number: $env:BUILD_NUMBER" - name: Install Node dependencies run: npm ci @@ -879,6 +879,13 @@ jobs: with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + - name: Retrieve Slack secret + id: retrieve-slack-secret + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: bitwarden-ci + secrets: "slack-bot-token" + - name: Download Provisioning Profiles secrets env: ACCOUNT_NAME: bitwardenci @@ -955,7 +962,7 @@ jobs: $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - "### MacOS App Store build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + Write-Output "### MacOS App Store build number: $env:BUILD_NUMBER" - name: Install Node dependencies run: npm ci @@ -1016,16 +1023,60 @@ jobs: if-no-files-found: error - name: Deploy to TestFlight + id: testflight-deploy if: | (github.ref == 'refs/heads/main' - && needs.setup.outputs.rc_branch_exists == 0 - && needs.setup.outputs.hotfix_branch_exists == 0) - || (github.ref == 'refs/heads/rc' && needs.setup.outputs.hotfix_branch_exists == 0) - || github.ref == 'refs/heads/hotfix-rc-desktop' + || github.ref == 'refs/heads/rc' + || github.ref == 'refs/heads/hotfix-rc-desktop') env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP - run: npm run upload:mas + run: | + xcrun altool \ + --upload-app \ + --type macos \ + --file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \ + --apiKey $APP_STORE_CONNECT_AUTH_KEY \ + --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER \ + &> output.txt + + UUID=$(cat output.txt | grep "Delivery UUID" | sed -E 's/Delivery UUID: (.*)/\1/') + echo "uuid=$UUID" >> $GITHUB_OUTPUT + + - name: Post message to a Slack channel + id: slack-message + if: | + (github.ref == 'refs/heads/main' + || github.ref == 'refs/heads/rc' + || github.ref == 'refs/heads/hotfix-rc-desktop') + uses: slackapi/slack-github-action@6c661ce58804a1a20f6dc5fbee7f0381b469e001 # v1.25.0 + with: + channel-id: C074F5UESQ0 + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Desktop client v${{ env._PACKAGE_VERSION }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|build> success on *${{ github.ref_name }}*" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "TestFlight Build", + "emoji": true + }, + "url": "https://appstoreconnect.apple.com/teams/${{ env.APP_STORE_CONNECT_TEAM_ISSUER }}/apps/1352778147/testflight/macos/${{ env.BUILD_UUID }}" + } + } + ] + } + env: + APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} + SLACK_BOT_TOKEN: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }} + BUILD_UUID: ${{ steps.testflight-deploy.outputs.uuid }} macos-package-dev: @@ -1158,7 +1209,7 @@ jobs: $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - "### MacOS Dev build number: $env:BUILD_NUMBER" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + Write-Output "### MacOS Dev build number: $env:BUILD_NUMBER" - name: Install Node dependencies run: npm ci From 51a2ec393c3174a2a3ed477431c748e4b9955692 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Mon, 16 Sep 2024 14:08:01 -0400 Subject: [PATCH 14/15] [PM-11693] Restoring/permanently deleting item from View item page takes you to Vault page (#10995) * route to trash after restore or delete * route to trash after restore or delete --- .../popup/components/vault-v2/view-v2/view-v2.component.ts | 5 +++-- .../trash-list-items-container.component.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1505d98ede2..4025beb685b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -152,7 +152,8 @@ export class ViewV2Component { return false; } - await this.router.navigate(["/vault"]); + const successRoute = this.cipher.isDeleted ? "/trash" : "/vault"; + await this.router.navigate([successRoute]); this.toastService.showToast({ variant: "success", title: null, @@ -169,7 +170,7 @@ export class ViewV2Component { this.logService.error(e); } - await this.router.navigate(["/vault"]); + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index 6d3bfc24838..de9d95aab00 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -58,7 +58,7 @@ export class TrashListItemsContainerComponent { try { await this.cipherService.restoreWithServer(cipher.id); - await this.router.navigate(["/vault"]); + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, @@ -89,7 +89,7 @@ export class TrashListItemsContainerComponent { try { await this.cipherService.deleteWithServer(cipher.id); - await this.router.navigate(["/vault"]); + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, From 26f3dcfc66d246e44976fb598fc65f3366581152 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:02:20 -0500 Subject: [PATCH 15/15] add back events for browser refresh extension (#11085) - something went sideways in a merge --- .../add-edit/add-edit-v2.component.spec.ts | 56 +++++++++++++++++++ .../add-edit/add-edit-v2.component.ts | 12 ++++ .../view-v2/view-v2.component.spec.ts | 19 +++++++ .../vault-v2/view-v2/view-v2.component.ts | 10 ++++ .../card-details-section.component.html | 2 + .../card-details-section.component.spec.ts | 2 + .../card-details-section.component.ts | 20 +++++++ .../custom-fields.component.html | 1 + .../custom-fields.component.spec.ts | 3 + .../custom-fields/custom-fields.component.ts | 18 ++++++ .../login-details-section.component.html | 2 + .../login-details-section.component.spec.ts | 34 +++++++++++ .../login-details-section.component.ts | 22 ++++++++ .../card-details-view.component.html | 3 + .../card-details-view.component.ts | 27 ++++++++- .../cipher-view/cipher-view.component.html | 5 +- .../custom-fields-v2.component.html | 11 +++- .../custom-fields-v2.component.ts | 34 +++++++++-- .../login-credentials-view.component.html | 1 + .../login-credentials-view.component.ts | 25 ++++++++- .../copy-cipher-field.service.spec.ts | 2 + .../src/services/copy-cipher-field.service.ts | 7 ++- 22 files changed, 300 insertions(+), 16 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index 77a50ea35d9..ebfb1ff765f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -3,6 +3,8 @@ import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -44,12 +46,14 @@ describe("AddEditV2Component", () => { const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); + const collect = jest.fn().mockResolvedValue(null); beforeEach(async () => { buildConfig.mockClear(); disable.mockClear(); navigate.mockClear(); back.mockClear(); + collect.mockClear(); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock(); @@ -66,6 +70,7 @@ describe("AddEditV2Component", () => { { provide: ActivatedRoute, useValue: { queryParams: queryParams$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: CipherService, useValue: cipherServiceMock }, + { provide: EventCollectionService, useValue: { collect } }, ], }) .overrideProvider(CipherFormConfigService, { @@ -122,6 +127,57 @@ describe("AddEditV2Component", () => { }); }); + describe("analytics", () => { + it("does not log viewed event when mode is add", fakeAsync(() => { + queryParams$.next({}); + + tick(); + + expect(collect).not.toHaveBeenCalled(); + })); + + it("does not log viewed event whe mode is clone", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555", clone: "true" }); + buildConfigResponse.originalCipher = {} as Cipher; + + tick(); + + expect(collect).not.toHaveBeenCalled(); + })); + + it("logs viewed event when mode is edit", fakeAsync(() => { + buildConfigResponse.originalCipher = { + edit: true, + id: "222-333-444-5555", + organizationId: "444-555-666", + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + + expect(collect).toHaveBeenCalledWith( + EventType.Cipher_ClientViewed, + "222-333-444-5555", + false, + "444-555-666", + ); + })); + + it("logs viewed event whe mode is partial-edit", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: false } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555", orgId: "444-555-666" }); + + tick(); + + expect(collect).toHaveBeenCalledWith( + EventType.Cipher_ClientViewed, + "222-333-444-5555", + false, + "444-555-666", + ); + })); + }); + describe("addEditCipherInfo initialization", () => { it("populates config.initialValues with `addEditCipherInfo` values", fakeAsync(() => { const addEditCipherInfo = { diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 7664c7e0ca1..5febed788e7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -6,6 +6,8 @@ import { ActivatedRoute, Params, Router } from "@angular/router"; import { firstValueFrom, map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -160,6 +162,7 @@ export class AddEditV2Component implements OnInit { private popupRouterCacheService: PopupRouterCacheService, private router: Router, private cipherService: CipherService, + private eventCollectionService: EventCollectionService, ) { this.subscribeToParams(); } @@ -275,6 +278,15 @@ export class AddEditV2Component implements OnInit { await this.cipherService.setAddEditCipherInfo(null); } + if (["edit", "partial-edit"].includes(config.mode) && config.originalCipher?.id) { + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + config.originalCipher.id, + false, + config.originalCipher.organizationId, + ); + } + return config; }), ) diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 9851b16aa41..69ee2dafe50 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -3,7 +3,9 @@ import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { Subject } from "rxjs"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EventType } from "@bitwarden/common/enums"; 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"; @@ -29,10 +31,12 @@ describe("ViewV2Component", () => { let fixture: ComponentFixture; const params$ = new Subject(); const mockNavigate = jest.fn(); + const collect = jest.fn().mockResolvedValue(null); const mockCipher = { id: "122-333-444", type: CipherType.Login, + orgId: "222-444-555", }; const mockVaultPopupAutofillService = { @@ -48,6 +52,7 @@ describe("ViewV2Component", () => { beforeEach(async () => { mockNavigate.mockClear(); + collect.mockClear(); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -59,6 +64,7 @@ describe("ViewV2Component", () => { { provide: ConfigService, useValue: mock() }, { provide: PopupRouterCacheService, useValue: mock() }, { provide: ActivatedRoute, useValue: { queryParams: params$ } }, + { provide: EventCollectionService, useValue: { collect } }, { provide: I18nService, useValue: { @@ -122,5 +128,18 @@ describe("ViewV2Component", () => { expect(component.headerText).toEqual("viewItemHeader note"); })); + + it("sends viewed event", fakeAsync(() => { + params$.next({ cipherId: "122-333-444" }); + + flush(); // Resolve all promises + + expect(collect).toHaveBeenCalledWith( + EventType.Cipher_ClientViewed, + mockCipher.id, + false, + undefined, + ); + })); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 4025beb685b..e8aab69dbe9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -6,9 +6,11 @@ import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -73,6 +75,7 @@ export class ViewV2Component { private toastService: ToastService, private vaultPopupAutofillService: VaultPopupAutofillService, private accountService: AccountService, + private eventCollectionService: EventCollectionService, ) { this.subscribeToParams(); } @@ -90,6 +93,13 @@ export class ViewV2Component { if (this.loadAction === AUTOFILL_ID || this.loadAction === SHOW_AUTOFILL_BUTTON) { await this.vaultPopupAutofillService.doAutofill(this.cipher); } + + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + cipher.id, + false, + cipher.organizationId, + ); }), takeUntilDestroyed(), ) diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html index 161f193108a..44d8a21d3c8 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html @@ -19,6 +19,7 @@ bitSuffix bitPasswordInputToggle data-testid="visibility-for-card-number" + (toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardNumberVisible)" >
@@ -60,6 +61,7 @@ bitSuffix bitPasswordInputToggle data-testid="visibility-for-card-code" + (toggledChange)="logCardEvent($event, EventType.Cipher_ClientToggledCardCodeVisible)" > diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts index 196be144fdd..e9581859b2e 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts @@ -4,6 +4,7 @@ import { ReactiveFormsModule } from "@angular/forms"; import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -27,6 +28,7 @@ describe("CardDetailsSectionComponent", () => { await TestBed.configureTestingModule({ imports: [CardDetailsSectionComponent, CommonModule, ReactiveFormsModule], providers: [ + { provide: EventCollectionService, useValue: mock() }, { provide: CipherFormContainer, useValue: cipherFormProvider }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index df45bcbcac0..e1ef3dc0f37 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -4,6 +4,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -91,10 +93,13 @@ export class CardDetailsSectionComponent implements OnInit { { name: "12 - " + this.i18nService.t("december"), value: "12" }, ]; + EventType = EventType; + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, private i18nService: I18nService, + private eventCollectionService: EventCollectionService, ) { this.cipherFormContainer.registerChildForm("cardDetails", this.cardDetailsForm); @@ -149,6 +154,21 @@ export class CardDetailsSectionComponent implements OnInit { return this.i18nService.t("cardDetails"); } + async logCardEvent(hiddenFieldVisible: boolean, event: EventType) { + const { mode, originalCipher } = this.cipherFormContainer.config; + + const isEdit = ["edit", "partial-edit"].includes(mode); + + if (hiddenFieldVisible && isEdit) { + await this.eventCollectionService.collect( + event, + originalCipher.id, + false, + originalCipher.organizationId, + ); + } + } + /** Set form initial form values from the current cipher */ private setInitialValues() { const { cardholderName, number, brand, expMonth, expYear, code } = this.originalCipherView.card; 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 1be4d922c53..0ab059b09c9 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 @@ -46,6 +46,7 @@ bitPasswordInputToggle data-testid="visibility-for-custom-hidden-field" [disabled]="!canViewPasswords(i)" + (toggledChange)="logHiddenEvent($event)" > diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts index 036a59672e0..0c0fa1b4184 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts @@ -4,7 +4,9 @@ import { CdkDragDrop } from "@angular/cdk/drag-drop"; import { DebugElement } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CardLinkedId, @@ -50,6 +52,7 @@ describe("CustomFieldsComponent", () => { await TestBed.configureTestingModule({ imports: [CustomFieldsComponent], providers: [ + { provide: EventCollectionService, useValue: mock() }, { provide: I18nService, useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") }, diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts index e2aa118b883..1aeb9e0da08 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -19,6 +19,8 @@ import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angul import { Subject, zip } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType, FieldType, LinkedIdType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; @@ -118,6 +120,7 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit { private formBuilder: FormBuilder, private i18nService: I18nService, private liveAnnouncer: LiveAnnouncer, + private eventCollectionService: EventCollectionService, ) { this.destroyed$ = inject(DestroyRef); this.cipherFormContainer.registerChildForm("customFields", this.customFieldsForm); @@ -301,6 +304,21 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit { } } + async logHiddenEvent(hiddenFieldVisible: boolean) { + const { mode, originalCipher } = this.cipherFormContainer.config; + + const isEdit = ["edit", "partial-edit"].includes(mode); + + if (hiddenFieldVisible && isEdit) { + await this.eventCollectionService.collect( + EventType.Cipher_ClientToggledHiddenFieldVisible, + originalCipher.id, + false, + originalCipher.organizationId, + ); + } + } + /** * Returns the linked field options for the current cipher type * diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html index af75eae862c..b91258a2183 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html @@ -57,6 +57,7 @@ *ngIf="viewHiddenFields" data-testid="toggle-password-visibility" bitPasswordInputToggle + (toggledChange)="logVisibleEvent($event, EventType.Cipher_ClientToggledPasswordVisible)" > diff --git a/libs/vault/src/cipher-view/card-details/card-details-view.component.ts b/libs/vault/src/cipher-view/card-details/card-details-view.component.ts index 6ab2795afd9..55868068cac 100644 --- a/libs/vault/src/cipher-view/card-details/card-details-view.component.ts +++ b/libs/vault/src/cipher-view/card-details/card-details-view.component.ts @@ -2,8 +2,10 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CardComponent, SectionComponent, @@ -32,9 +34,17 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only- ], }) export class CardDetailsComponent { - @Input() card: CardView; + @Input() cipher: CipherView; + EventType = EventType; - constructor(private i18nService: I18nService) {} + constructor( + private i18nService: I18nService, + private eventCollectionService: EventCollectionService, + ) {} + + get card() { + return this.cipher.card; + } get setSectionTitle() { if (this.card.brand && this.card.brand !== "Other") { @@ -42,4 +52,15 @@ export class CardDetailsComponent { } return this.i18nService.t("cardDetails"); } + + async logCardEvent(conditional: boolean, event: EventType) { + if (conditional) { + await this.eventCollectionService.collect( + event, + this.cipher.id, + false, + this.cipher.organizationId, + ); + } + } } diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 9b4bfdb5970..c34fc05b1d3 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -29,7 +29,7 @@ - + @@ -42,8 +42,7 @@ - - + diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html index 69aa4e9d699..6c1b8ee5f1d 100644 --- a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html @@ -5,7 +5,7 @@
@@ -24,7 +24,13 @@ {{ field.name }} - + diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts index b5826d82edf..01f05765bcb 100644 --- a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts @@ -2,10 +2,12 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType, FieldType, LinkedIdType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; -import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { @@ -37,12 +39,14 @@ import { ], }) export class CustomFieldV2Component implements OnInit { - @Input() fields: FieldView[]; - @Input() cipherType: CipherType; + @Input() cipher: CipherView; fieldType = FieldType; fieldOptions: any; - constructor(private i18nService: I18nService) {} + constructor( + private i18nService: I18nService, + private eventCollectionService: EventCollectionService, + ) {} ngOnInit(): void { this.fieldOptions = this.getLinkedFieldsOptionsForCipher(); @@ -53,8 +57,28 @@ export class CustomFieldV2Component implements OnInit { return this.i18nService.t(linkedType.i18nKey); } + async logHiddenEvent(hiddenFieldVisible: boolean) { + if (hiddenFieldVisible) { + await this.eventCollectionService.collect( + EventType.Cipher_ClientToggledHiddenFieldVisible, + this.cipher.id, + false, + this.cipher.organizationId, + ); + } + } + + async logCopyEvent() { + await this.eventCollectionService.collect( + EventType.Cipher_ClientCopiedHiddenField, + this.cipher.id, + false, + this.cipher.organizationId, + ); + } + private getLinkedFieldsOptionsForCipher() { - switch (this.cipherType) { + switch (this.cipher.type) { case CipherType.Login: return LoginView.prototype.linkedFieldOptions; case CipherType.Card: 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 f2fbb6a0426..1d6c81c6591 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 @@ -66,6 +66,7 @@ showToast [appA11yTitle]="'copyValue' | i18n" data-testid="copy-password" + (click)="logCopyEvent()" >
{ expect(eventCollectionService.collect).toHaveBeenCalledWith( EventType.Cipher_ClientCopiedPassword, cipher.id, + false, + cipher.organizationId, ); }); }); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 82a9542feb2..4767ae01bca 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -125,7 +125,12 @@ export class CopyCipherFieldService { }); if (action.event !== undefined) { - await this.eventCollectionService.collect(action.event, cipher.id); + await this.eventCollectionService.collect( + action.event, + cipher.id, + false, + cipher.organizationId, + ); } }