diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4e3f7b7abc1..db1f960b9b3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2000,6 +2000,12 @@ "nativeMessagingWrongUserTitle": { "message": "Account missmatch" }, + "nativeMessagingWrongUserKeyDesc": { + "message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again." + }, + "nativeMessagingWrongUserKeyTitle": { + "message": "Biometric key missmatch" + }, "biometricsNotEnabledTitle": { "message": "Biometrics not set up" }, @@ -4091,6 +4097,12 @@ "itemLocation": { "message": "Item Location" }, + "fileSends": { + "message": "File Sends" + }, + "textSends": { + "message": "Text Sends" + }, "bitwardenNewLook": { "message": "Bitwarden has a new look!" }, diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts new file mode 100644 index 00000000000..1b844d4b2c7 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts @@ -0,0 +1,23 @@ +import { Observable, Subject } from "rxjs"; + +import { + AnonLayoutWrapperDataService, + DefaultAnonLayoutWrapperDataService, +} from "@bitwarden/auth/angular"; + +import { ExtensionAnonLayoutWrapperData } from "./extension-anon-layout-wrapper.component"; + +export class ExtensionAnonLayoutWrapperDataService + extends DefaultAnonLayoutWrapperDataService + implements AnonLayoutWrapperDataService +{ + protected override anonLayoutWrapperDataSubject = new Subject(); + + override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void { + this.anonLayoutWrapperDataSubject.next(data); + } + + override anonLayoutWrapperData$(): Observable { + return this.anonLayoutWrapperDataSubject.asObservable(); + } +} diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html new file mode 100644 index 00000000000..e7082f40196 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts new file mode 100644 index 00000000000..df6e313342b --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -0,0 +1,190 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; +import { Subject, filter, firstValueFrom, switchMap, takeUntil, tap } from "rxjs"; + +import { + AnonLayoutComponent, + AnonLayoutWrapperData, + AnonLayoutWrapperDataService, +} from "@bitwarden/auth/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { Icon, IconModule } from "@bitwarden/components"; + +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { CurrentAccountComponent } from "../account-switching/current-account.component"; + +import { + ExtensionBitwardenLogoPrimary, + ExtensionBitwardenLogoWhite, +} from "./extension-bitwarden-logo.icon"; + +export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { + showAcctSwitcher?: boolean; + showBackButton?: boolean; + showLogo?: boolean; +} + +@Component({ + standalone: true, + templateUrl: "extension-anon-layout-wrapper.component.html", + imports: [ + AnonLayoutComponent, + CommonModule, + CurrentAccountComponent, + IconModule, + PopOutComponent, + PopupPageComponent, + PopupHeaderComponent, + RouterModule, + ], +}) +export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + protected showAcctSwitcher: boolean; + protected showBackButton: boolean; + protected showLogo: boolean = true; + + protected pageTitle: string; + protected pageSubtitle: string; + protected pageIcon: Icon; + protected showReadonlyHostname: boolean; + protected maxWidth: "md" | "3xl"; + + protected theme: string; + protected logo: Icon; + + constructor( + private router: Router, + private route: ActivatedRoute, + private i18nService: I18nService, + private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private themeStateService: ThemeStateService, + ) {} + + async ngOnInit(): Promise { + // Set the initial page data on load + this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data); + + // Listen for page changes and update the page data appropriately + this.listenForPageDataChanges(); + this.listenForServiceDataChanges(); + + this.theme = await firstValueFrom(this.themeStateService.selectedTheme$); + + if (this.theme === "dark") { + this.logo = ExtensionBitwardenLogoWhite; + } else { + this.logo = ExtensionBitwardenLogoPrimary; + } + } + + private listenForPageDataChanges() { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + // reset page data on page changes + tap(() => this.resetPageData()), + switchMap(() => this.route.firstChild?.data || null), + takeUntil(this.destroy$), + ) + .subscribe((firstChildRouteData: Data | null) => { + this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData); + }); + } + + private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) { + if (!firstChildRouteData) { + return; + } + + if (firstChildRouteData["pageTitle"] !== undefined) { + this.pageTitle = this.i18nService.t(firstChildRouteData["pageTitle"]); + } + + if (firstChildRouteData["pageSubtitle"] !== undefined) { + this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]); + } + + if (firstChildRouteData["pageIcon"] !== undefined) { + this.pageIcon = firstChildRouteData["pageIcon"]; + } + + this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); + this.maxWidth = firstChildRouteData["maxWidth"]; + + if (firstChildRouteData["showAcctSwitcher"] !== undefined) { + this.showAcctSwitcher = Boolean(firstChildRouteData["showAcctSwitcher"]); + } + + if (firstChildRouteData["showBackButton"] !== undefined) { + this.showBackButton = Boolean(firstChildRouteData["showBackButton"]); + } + + if (firstChildRouteData["showLogo"] !== undefined) { + this.showLogo = Boolean(firstChildRouteData["showLogo"]); + } + } + + private listenForServiceDataChanges() { + this.extensionAnonLayoutWrapperDataService + .anonLayoutWrapperData$() + .pipe(takeUntil(this.destroy$)) + .subscribe((data: ExtensionAnonLayoutWrapperData) => { + this.setAnonLayoutWrapperData(data); + }); + } + + private setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData) { + if (!data) { + return; + } + + if (data.pageTitle) { + this.pageTitle = this.i18nService.t(data.pageTitle); + } + + if (data.pageSubtitle) { + this.pageSubtitle = this.i18nService.t(data.pageSubtitle); + } + + if (data.pageIcon) { + this.pageIcon = data.pageIcon; + } + + if (data.showReadonlyHostname != null) { + this.showReadonlyHostname = data.showReadonlyHostname; + } + + if (data.showAcctSwitcher != null) { + this.showAcctSwitcher = data.showAcctSwitcher; + } + + if (data.showBackButton != null) { + this.showBackButton = data.showBackButton; + } + + if (data.showLogo != null) { + this.showLogo = data.showLogo; + } + } + + private resetPageData() { + this.pageTitle = null; + this.pageSubtitle = null; + this.pageIcon = null; + this.showReadonlyHostname = null; + this.showAcctSwitcher = null; + this.showBackButton = null; + this.showLogo = null; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts new file mode 100644 index 00000000000..c447ccffd78 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -0,0 +1,294 @@ +import { importProvidersFrom, Component } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { + Meta, + StoryObj, + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from "@storybook/angular"; +import { of } from "rxjs"; + +import { AnonLayoutWrapperDataService, LockIcon } from "@bitwarden/auth/angular"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + EnvironmentService, + Environment, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ButtonModule, I18nMockService } from "@bitwarden/components"; + +import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon"; + +import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service"; +import { + ExtensionAnonLayoutWrapperComponent, + ExtensionAnonLayoutWrapperData, +} from "./extension-anon-layout-wrapper.component"; + +export default { + title: "Auth/Extension Anon Layout Wrapper", + component: ExtensionAnonLayoutWrapperComponent, +} as Meta; + +const decorators = (options: { + components: any[]; + routes: Routes; + applicationVersion?: string; + clientType?: ClientType; + hostName?: string; + themeType?: ThemeType; +}) => { + return [ + componentWrapperDecorator( + /** + * Applying a CSS transform makes a `position: fixed` element act like it is `position: relative` + * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 + */ + (story) => { + return /* HTML */ `
${story}
`; + }, + ({ globals }) => { + /** + * avoid a bug with the way that we render the same component twice in the same iframe and how + * that interacts with the router-outlet + */ + const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; + return { theme: themeOverride }; + }, + ), + moduleMetadata({ + declarations: options.components, + imports: [RouterModule, ButtonModule], + providers: [ + { + provide: AnonLayoutWrapperDataService, + useClass: ExtensionAnonLayoutWrapperDataService, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "test-user-id" as UserId, + name: "Test User 1", + email: "test@email.com", + emailVerified: true, + }), + }, + }, + { + provide: AuthService, + useValue: { + activeAccountStatus$: of(AuthenticationStatus.Unlocked), + }, + }, + { + provide: AvatarService, + useValue: { + avatarColor$: of("#ab134a"), + } as Partial, + }, + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => true, + }, + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getHostname: () => options.hostName || "storybook.bitwarden.com", + } as Partial), + } as Partial, + }, + { + provide: PlatformUtilsService, + useValue: { + getApplicationVersion: () => + Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"), + getClientType: () => options.clientType || ClientType.Web, + } as Partial, + }, + { + provide: ThemeStateService, + useValue: { + selectedTheme$: of(options.themeType || ThemeType.Light), + } as Partial, + }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + setAStrongPassword: "Set a strong password", + finishCreatingYourAccountBySettingAPassword: + "Finish creating your account by setting a password", + enterpriseSingleSignOn: "Enterprise single sign-on", + checkYourEmail: "Check your email", + loading: "Loading", + popOutNewWindow: "Pop out to a new window", + switchAccounts: "Switch accounts", + back: "Back", + activeAccount: "Active account", + }); + }, + }, + ], + }), + applicationConfig({ + providers: [importProvidersFrom(RouterModule.forRoot(options.routes))], + }), + ]; +}; + +type Story = StoryObj; + +// Default Example + +@Component({ + selector: "bit-default-primary-outlet-example-component", + template: "

Primary Outlet Example:
your primary component goes here

", +}) +class DefaultPrimaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-secondary-outlet-example-component", + template: "

Secondary Outlet Example:
your secondary component goes here

", +}) +class DefaultSecondaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-env-selector-outlet-example-component", + template: "

Env Selector Outlet Example:
your env selector component goes here

", +}) +class DefaultEnvSelectorOutletExampleComponent {} + +export const DefaultContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [ + DefaultPrimaryOutletExampleComponent, + DefaultSecondaryOutletExampleComponent, + DefaultEnvSelectorOutletExampleComponent, + ], + routes: [ + { + path: "**", + redirectTo: "default-example", + pathMatch: "full", + }, + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "default-example", + data: {}, + children: [ + { + path: "", + component: DefaultPrimaryOutletExampleComponent, + }, + { + path: "", + component: DefaultSecondaryOutletExampleComponent, + outlet: "secondary", + }, + { + path: "", + component: DefaultEnvSelectorOutletExampleComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ], + }), +}; + +// Dynamic Content Example +const initialData: ExtensionAnonLayoutWrapperData = { + pageTitle: "setAStrongPassword", + pageSubtitle: "finishCreatingYourAccountBySettingAPassword", + pageIcon: LockIcon, + showAcctSwitcher: true, + showBackButton: true, + showLogo: true, +}; + +const changedData: ExtensionAnonLayoutWrapperData = { + pageTitle: "enterpriseSingleSignOn", + pageSubtitle: "checkYourEmail", + pageIcon: RegistrationCheckEmailIcon, + showAcctSwitcher: false, + showBackButton: false, + showLogo: false, +}; + +@Component({ + selector: "bit-dynamic-content-example-component", + template: ` + + `, +}) +export class DynamicContentExampleComponent { + initialData = true; + + constructor(private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService) {} + + toggleData() { + if (this.initialData) { + this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData); + } else { + this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData); + } + + this.initialData = !this.initialData; + } +} + +export const DynamicContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [DynamicContentExampleComponent], + routes: [ + { + path: "**", + redirectTo: "dynamic-content-example", + pathMatch: "full", + }, + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "dynamic-content-example", + data: initialData, + children: [ + { + path: "", + component: DynamicContentExampleComponent, + }, + ], + }, + ], + }, + ], + }), +}; diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts new file mode 100644 index 00000000000..569edaae978 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts @@ -0,0 +1,35 @@ +import { svgIcon } from "@bitwarden/components"; + +export const ExtensionBitwardenLogoPrimary = svgIcon` + + + +`; + +export const ExtensionBitwardenLogoWhite = svgIcon` + + + +`; diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 401196b256f..8122f5c4ed9 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -95,6 +95,10 @@ export type OverlayAddNewItemMessage = { identity?: NewIdentityCipherData; }; +export type CurrentAddNewItemData = OverlayAddNewItemMessage & { + sender: chrome.runtime.MessageSender; +}; + export type CloseInlineMenuMessage = { forceCloseInlineMenu?: boolean; overlayElement?: string; @@ -161,7 +165,7 @@ export type OverlayBackgroundExtensionMessageHandlers = { triggerAutofillOverlayReposition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; checkIsInlineMenuCiphersPopulated: ({ sender }: BackgroundSenderParam) => void; updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; - updateIsFieldCurrentlyFocused: ({ message }: BackgroundMessageParam) => void; + updateIsFieldCurrentlyFocused: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; checkIsFieldCurrentlyFocused: () => boolean; updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void; checkIsFieldCurrentlyFilling: () => boolean; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 783010ee04d..fe118868628 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -176,8 +176,12 @@ describe("OverlayBackground", () => { parentFrameId: getFrameCounter, }); }); - tabsSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage"); - tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData"); + tabsSendMessageSpy = jest + .spyOn(BrowserApi, "tabSendMessage") + .mockImplementation(() => Promise.resolve()); + tabSendMessageDataSpy = jest + .spyOn(BrowserApi, "tabSendMessageData") + .mockImplementation(() => Promise.resolve()); sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); getTabSpy = jest.spyOn(BrowserApi, "getTab"); @@ -526,10 +530,13 @@ describe("OverlayBackground", () => { }); it("skips updating the position of either inline menu element if a field is not currently focused", async () => { - sendMockExtensionMessage({ - command: "updateIsFieldCurrentlyFocused", - isFieldCurrentlyFocused: false, - }); + sendMockExtensionMessage( + { + command: "updateIsFieldCurrentlyFocused", + isFieldCurrentlyFocused: false, + }, + mock({ frameId: 20 }), + ); sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender); await flushUpdateInlineMenuPromises(); @@ -835,7 +842,7 @@ describe("OverlayBackground", () => { it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => { overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id }); - cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1]); cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab); @@ -854,7 +861,7 @@ describe("OverlayBackground", () => { image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", imageEnabled: true, }, - id: "inline-menu-cipher-1", + id: "inline-menu-cipher-0", login: { username: "username-1", }, @@ -1116,10 +1123,12 @@ describe("OverlayBackground", () => { let openAddEditVaultItemPopoutSpy: jest.SpyInstance; beforeEach(() => { + jest.useFakeTimers(); sender = mock({ tab: { id: 1 } }); openAddEditVaultItemPopoutSpy = jest .spyOn(overlayBackground as any, "openAddEditVaultItemPopout") .mockImplementation(); + overlayBackground["currentAddNewItemData"] = { sender, addNewCipherType: CipherType.Login }; }); it("will not open the add edit popout window if the message does not have a login cipher provided", () => { @@ -1129,6 +1138,28 @@ describe("OverlayBackground", () => { expect(openAddEditVaultItemPopoutSpy).not.toHaveBeenCalled(); }); + it("resets the currentAddNewItemData to null when a cipher view is not successfully created", async () => { + jest.spyOn(overlayBackground as any, "buildLoginCipherView").mockReturnValue(null); + + sendMockExtensionMessage( + { + command: "autofillOverlayAddNewVaultItem", + addNewCipherType: CipherType.Login, + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender, + ); + jest.advanceTimersByTime(100); + await flushPromises(); + + expect(overlayBackground["currentAddNewItemData"]).toBeNull(); + }); + it("will open the add edit popout window after creating a new cipher", async () => { sendMockExtensionMessage( { @@ -1143,6 +1174,7 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); @@ -1151,6 +1183,8 @@ describe("OverlayBackground", () => { }); it("creates a new card cipher", async () => { + overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Card; + sendMockExtensionMessage( { command: "autofillOverlayAddNewVaultItem", @@ -1166,6 +1200,7 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); @@ -1174,6 +1209,10 @@ describe("OverlayBackground", () => { }); describe("creating a new identity cipher", () => { + beforeEach(() => { + overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Identity; + }); + it("populates an identity cipher view and creates it", async () => { sendMockExtensionMessage( { @@ -1200,6 +1239,7 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); @@ -1220,6 +1260,7 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); @@ -1238,6 +1279,7 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); @@ -1256,11 +1298,173 @@ describe("OverlayBackground", () => { }, sender, ); + jest.advanceTimersByTime(100); await flushPromises(); expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled(); }); }); + + describe("pulling cipher data from multiple frames of a tab", () => { + let subFrameSender: MockProxy; + const command = "autofillOverlayAddNewVaultItem"; + + beforeEach(() => { + subFrameSender = mock({ tab: sender.tab, frameId: 2 }); + }); + + it("combines the login cipher data from all frames", async () => { + const buildLoginCipherViewSpy = jest.spyOn( + overlayBackground as any, + "buildLoginCipherView", + ); + const addNewCipherType = CipherType.Login; + const loginCipherData = { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "", + }; + const subFrameLoginCipherData = { + uri: "https://tacos.com", + hostname: "tacos.com", + username: "", + password: "password", + }; + + sendMockExtensionMessage({ command, addNewCipherType, login: loginCipherData }, sender); + sendMockExtensionMessage( + { command, addNewCipherType, login: subFrameLoginCipherData }, + subFrameSender, + ); + jest.advanceTimersByTime(100); + await flushPromises(); + + expect(buildLoginCipherViewSpy).toHaveBeenCalledWith({ + uri: "https://tacos.com", + hostname: "tacos.com", + username: "username", + password: "password", + }); + }); + + it("combines the card cipher data from all frames", async () => { + const buildCardCipherViewSpy = jest.spyOn( + overlayBackground as any, + "buildCardCipherView", + ); + overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Card; + const addNewCipherType = CipherType.Card; + const cardCipherData = { + cardholderName: "cardholderName", + number: "", + expirationMonth: "", + expirationYear: "", + expirationDate: "12/25", + cvv: "123", + }; + const subFrameCardCipherData = { + cardholderName: "", + number: "4242424242424242", + expirationMonth: "12", + expirationYear: "2025", + expirationDate: "", + cvv: "", + }; + + sendMockExtensionMessage({ command, addNewCipherType, card: cardCipherData }, sender); + sendMockExtensionMessage( + { command, addNewCipherType, card: subFrameCardCipherData }, + subFrameSender, + ); + jest.advanceTimersByTime(100); + await flushPromises(); + + expect(buildCardCipherViewSpy).toHaveBeenCalledWith({ + cardholderName: "cardholderName", + number: "4242424242424242", + expirationMonth: "12", + expirationYear: "2025", + expirationDate: "12/25", + cvv: "123", + }); + }); + + it("combines the identity cipher data from all frames", async () => { + const buildIdentityCipherViewSpy = jest.spyOn( + overlayBackground as any, + "buildIdentityCipherView", + ); + overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Identity; + const addNewCipherType = CipherType.Identity; + const identityCipherData = { + title: "title", + firstName: "firstName", + middleName: "middleName", + lastName: "", + fullName: "", + address1: "address1", + address2: "address2", + address3: "address3", + city: "city", + state: "state", + postalCode: "postalCode", + country: "country", + company: "company", + phone: "phone", + email: "email", + username: "username", + }; + const subFrameIdentityCipherData = { + title: "", + firstName: "", + middleName: "", + lastName: "lastName", + fullName: "fullName", + address1: "", + address2: "", + address3: "", + city: "", + state: "", + postalCode: "", + country: "", + company: "", + phone: "", + email: "", + username: "", + }; + + sendMockExtensionMessage( + { command, addNewCipherType, identity: identityCipherData }, + sender, + ); + sendMockExtensionMessage( + { command, addNewCipherType, identity: subFrameIdentityCipherData }, + subFrameSender, + ); + jest.advanceTimersByTime(100); + await flushPromises(); + + expect(buildIdentityCipherViewSpy).toHaveBeenCalledWith({ + title: "title", + firstName: "firstName", + middleName: "middleName", + lastName: "lastName", + fullName: "fullName", + address1: "address1", + address2: "address2", + address3: "address3", + city: "city", + state: "state", + postalCode: "postalCode", + country: "country", + company: "company", + phone: "phone", + email: "email", + username: "username", + }); + }); + }); }); describe("checkIsInlineMenuCiphersPopulated message handler", () => { @@ -1360,6 +1564,70 @@ describe("OverlayBackground", () => { showInlineMenuAccountCreation: true, }); }); + + it("triggers an update of the inline menu ciphers when the new focused field's cipher type does not equal the previous focused field's cipher type", async () => { + const updateOverlayCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers"); + const tab = createChromeTabMock({ id: 2 }); + const sender = mock({ tab, frameId: 100 }); + const focusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: sender.frameId, + filledByCipherType: CipherType.Login, + }); + sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender); + await flushPromises(); + + const newFocusedFieldData = createFocusedFieldDataMock({ + tabId: tab.id, + frameId: sender.frameId, + filledByCipherType: CipherType.Card, + }); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData: newFocusedFieldData }, + sender, + ); + await flushPromises(); + + expect(updateOverlayCiphersSpy).toHaveBeenCalled(); + }); + }); + + describe("updateIsFieldCurrentlyFocused message handler", () => { + it("skips updating the isFiledCurrentlyFocused value when the focused field data is populated and the sender frame id does not equal the focused field's frame id", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + mock({ tab: { id: 1 }, frameId: 10 }), + ); + overlayBackground["isFieldCurrentlyFocused"] = true; + + sendMockExtensionMessage( + { command: "updateIsFieldCurrentlyFocused", isFieldCurrentlyFocused: false }, + mock({ tab: { id: 1 }, frameId: 20 }), + ); + await flushPromises(); + + expect(overlayBackground["isFieldCurrentlyFocused"]).toBe(true); + }); + }); + + describe("updateIsFieldCurrentlyFocused message handler", () => { + it("skips updating the isFiledCurrentlyFocused value when the focused field data is populated and the sender frame id does not equal the focused field's frame id", async () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendMockExtensionMessage( + { command: "updateFocusedFieldData", focusedFieldData }, + mock({ tab: { id: 1 }, frameId: 10 }), + ); + overlayBackground["isFieldCurrentlyFocused"] = true; + + sendMockExtensionMessage( + { command: "updateIsFieldCurrentlyFocused", isFieldCurrentlyFocused: false }, + mock({ tab: { id: 1 }, frameId: 20 }), + ); + await flushPromises(); + + expect(overlayBackground["isFieldCurrentlyFocused"]).toBe(true); + }); }); describe("checkIsFieldCurrentlyFocused message handler", () => { @@ -1819,7 +2087,6 @@ describe("OverlayBackground", () => { overlayBackground["subFrameOffsetsForTab"][focusedFieldData.tabId] = new Map([ [focusedFieldData.frameId, null], ]); - tabsSendMessageSpy.mockImplementation(); jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent"); sendMockExtensionMessage( @@ -2068,7 +2335,6 @@ describe("OverlayBackground", () => { describe("autofillInlineMenuButtonClicked message handler", () => { it("opens the unlock vault popout if the user auth status is not unlocked", async () => { activeAccountStatusMock$.next(AuthenticationStatus.Locked); - tabsSendMessageSpy.mockImplementation(); sendPortMessage(buttonMessageConnectorSpy, { command: "autofillInlineMenuButtonClicked", @@ -2269,7 +2535,6 @@ describe("OverlayBackground", () => { describe("unlockVault message handler", () => { it("opens the unlock vault popout", async () => { activeAccountStatusMock$.next(AuthenticationStatus.Locked); - tabsSendMessageSpy.mockImplementation(); sendPortMessage(listMessageConnectorSpy, { command: "unlockVault", portKey }); await flushPromises(); @@ -2421,11 +2686,10 @@ describe("OverlayBackground", () => { }); await flushPromises(); - expect(tabsSendMessageSpy).toHaveBeenCalledWith( - sender.tab, - { command: "addNewVaultItemFromOverlay", addNewCipherType: CipherType.Login }, - { frameId: sender.frameId }, - ); + expect(tabsSendMessageSpy).toHaveBeenCalledWith(sender.tab, { + command: "addNewVaultItemFromOverlay", + addNewCipherType: CipherType.Login, + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 029d2d69ac6..8c4dac56d50 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -42,6 +42,7 @@ import { generateRandomChars } from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { CloseInlineMenuMessage, + CurrentAddNewItemData, FocusedFieldData, InlineMenuButtonPortMessageHandlers, InlineMenuCipherData, @@ -83,6 +84,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { private cancelUpdateInlineMenuPositionSubject = new Subject(); private repositionInlineMenuSubject = new Subject(); private rebuildSubFrameOffsetsSubject = new Subject(); + private addNewVaultItemSubject = new Subject(); + private currentAddNewItemData: CurrentAddNewItemData; private focusedFieldData: FocusedFieldData; private isFieldCurrentlyFocused: boolean = false; private isFieldCurrentlyFilling: boolean = false; @@ -97,7 +100,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { checkIsInlineMenuCiphersPopulated: ({ sender }) => this.checkIsInlineMenuCiphersPopulated(sender), updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), - updateIsFieldCurrentlyFocused: ({ message }) => this.updateIsFieldCurrentlyFocused(message), + updateIsFieldCurrentlyFocused: ({ message, sender }) => + this.updateIsFieldCurrentlyFocused(message, sender), checkIsFieldCurrentlyFocused: () => this.checkIsFieldCurrentlyFocused(), updateIsFieldCurrentlyFilling: ({ message }) => this.updateIsFieldCurrentlyFilling(message), checkIsFieldCurrentlyFilling: () => this.checkIsFieldCurrentlyFilling(), @@ -186,6 +190,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { switchMap((sender) => this.rebuildSubFrameOffsets(sender)), ) .subscribe(); + this.addNewVaultItemSubject + .pipe( + debounceTime(100), + switchMap((addNewItemData) => + this.buildCipherAndOpenAddEditVaultItemPopout(addNewItemData), + ), + ) + .subscribe(); // Debounce used to update inline menu position merge( @@ -230,14 +242,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); if (authStatus !== AuthenticationStatus.Unlocked) { if (this.focusedFieldData) { - void this.closeInlineMenuAfterCiphersUpdate(); + this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error)); } return; } const currentTab = await BrowserApi.getTabFromCurrentWindowId(); if (this.focusedFieldData && currentTab?.id !== this.focusedFieldData.tabId) { - void this.closeInlineMenuAfterCiphersUpdate(); + this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error)); } this.inlineMenuCiphers = new Map(); @@ -318,7 +330,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { private async getInlineMenuCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers); - let inlineMenuCipherData: InlineMenuCipherData[] = []; + let inlineMenuCipherData: InlineMenuCipherData[]; if (this.showInlineMenuAccountCreation()) { inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers( @@ -526,10 +538,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { }; if (pageDetails.frameId !== 0 && pageDetails.details.fields.length) { - void this.buildSubFrameOffsets(pageDetails.tab, pageDetails.frameId, pageDetails.details.url); - void BrowserApi.tabSendMessage(pageDetails.tab, { + this.buildSubFrameOffsets( + pageDetails.tab, + pageDetails.frameId, + pageDetails.details.url, + ).catch((error) => this.logService.error(error)); + BrowserApi.tabSendMessage(pageDetails.tab, { command: "setupRebuildSubFrameOffsetsListeners", - }); + }).catch((error) => this.logService.error(error)); } const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; @@ -619,11 +635,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (!subFrameOffset) { subFrameOffsetsForTab.set(frameId, null); - void BrowserApi.tabSendMessage( + BrowserApi.tabSendMessage( tab, { command: "getSubFrameOffsetsFromWindowMessage", subFrameId: frameId }, { frameId }, - ); + ).catch((error) => this.logService.error(error)); return; } @@ -655,11 +671,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { frameId, ); - void BrowserApi.tabSendMessage( + BrowserApi.tabSendMessage( tab, { command: "destroyAutofillInlineMenuListeners" }, { frameId }, - ); + ).catch((error) => this.logService.error(error)); } /** @@ -695,13 +711,15 @@ export class OverlayBackground implements OverlayBackgroundInterface { } if (!this.checkIsInlineMenuButtonVisible()) { - void this.toggleInlineMenuHidden( + this.toggleInlineMenuHidden( { isInlineMenuHidden: false, setTransparentInlineMenu: true }, sender, - ); + ).catch((error) => this.logService.error(error)); } - void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender); + this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender).catch( + (error) => this.logService.error(error), + ); const mostRecentlyFocusedFieldHasValue = await BrowserApi.tabSendMessage( sender.tab, @@ -721,7 +739,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender); + this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender).catch( + (error) => this.logService.error(error), + ); } /** @@ -806,7 +826,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { const command = "closeAutofillInlineMenu"; const sendOptions = { frameId: 0 }; if (forceCloseInlineMenu) { - void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch( + (error) => this.logService.error(error), + ); this.isInlineMenuButtonVisible = false; this.isInlineMenuListVisible = false; return; @@ -817,11 +839,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { } if (this.isFieldCurrentlyFilling) { - void BrowserApi.tabSendMessage( + BrowserApi.tabSendMessage( sender.tab, { command, overlayElement: AutofillOverlayElement.List }, sendOptions, - ); + ).catch((error) => this.logService.error(error)); this.isInlineMenuListVisible = false; return; } @@ -839,7 +861,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.isInlineMenuListVisible = false; } - void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions); + BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch((error) => + this.logService.error(error), + ); } /** @@ -1090,23 +1114,34 @@ export class OverlayBackground implements OverlayBackgroundInterface { { focusedFieldData }: OverlayBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ) { - if (this.focusedFieldData?.frameId && this.focusedFieldData.frameId !== sender.frameId) { - void BrowserApi.tabSendMessage( + if (this.focusedFieldData && !this.senderFrameHasFocusedField(sender)) { + BrowserApi.tabSendMessage( sender.tab, { command: "unsetMostRecentlyFocusedField" }, { frameId: this.focusedFieldData.frameId }, - ); + ).catch((error) => this.logService.error(error)); } const previousFocusedFieldData = this.focusedFieldData; this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId }; + this.isFieldCurrentlyFocused = true; const accountCreationFieldBlurred = previousFocusedFieldData?.showInlineMenuAccountCreation && !this.focusedFieldData.showInlineMenuAccountCreation; if (accountCreationFieldBlurred || this.showInlineMenuAccountCreation()) { - void this.updateIdentityCiphersOnLoginField(previousFocusedFieldData); + this.updateIdentityCiphersOnLoginField(previousFocusedFieldData).catch((error) => + this.logService.error(error), + ); + return; + } + + if (previousFocusedFieldData?.filledByCipherType !== focusedFieldData?.filledByCipherType) { + const updateAllCipherTypes = focusedFieldData.filledByCipherType !== CipherType.Login; + this.updateOverlayCiphers(updateAllCipherTypes).catch((error) => + this.logService.error(error), + ); } } @@ -1353,9 +1388,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - void BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", { + BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", { direction, - }); + }).catch((error) => this.logService.error(error)); } /** @@ -1373,13 +1408,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } - void BrowserApi.tabSendMessage( - sender.tab, - { command: "addNewVaultItemFromOverlay", addNewCipherType }, - { - frameId: this.focusedFieldData.frameId || 0, - }, - ); + this.currentAddNewItemData = { addNewCipherType, sender }; + BrowserApi.tabSendMessage(sender.tab, { + command: "addNewVaultItemFromOverlay", + addNewCipherType, + }).catch((error) => this.logService.error(error)); } /** @@ -1396,18 +1429,154 @@ export class OverlayBackground implements OverlayBackgroundInterface { { addNewCipherType, login, card, identity }: OverlayAddNewItemMessage, sender: chrome.runtime.MessageSender, ) { - if (!addNewCipherType) { + if ( + !this.currentAddNewItemData || + sender.tab.id !== this.currentAddNewItemData.sender.tab.id || + !addNewCipherType || + this.currentAddNewItemData.addNewCipherType !== addNewCipherType + ) { return; } + if (login && this.isAddingNewLogin()) { + this.updateCurrentAddNewItemLogin(login); + } + + if (card && this.isAddingNewCard()) { + this.updateCurrentAddNewItemCard(card); + } + + if (identity && this.isAddingNewIdentity()) { + this.updateCurrentAddNewItemIdentity(identity); + } + + this.addNewVaultItemSubject.next(this.currentAddNewItemData); + } + + /** + * Identifies if the current add new item data is for adding a new login. + */ + private isAddingNewLogin() { + return this.currentAddNewItemData.addNewCipherType === CipherType.Login; + } + + /** + * Identifies if the current add new item data is for adding a new card. + */ + private isAddingNewCard() { + return this.currentAddNewItemData.addNewCipherType === CipherType.Card; + } + + /** + * Identifies if the current add new item data is for adding a new identity. + */ + private isAddingNewIdentity() { + return this.currentAddNewItemData.addNewCipherType === CipherType.Identity; + } + + /** + * Updates the current add new item data with the provided login data. If the + * login data is already present, the data will be merged with the existing data. + * + * @param login - The login data captured from the extension message + */ + private updateCurrentAddNewItemLogin(login: NewLoginCipherData) { + if (!this.currentAddNewItemData.login) { + this.currentAddNewItemData.login = login; + return; + } + + const currentLoginData = this.currentAddNewItemData.login; + this.currentAddNewItemData.login = { + uri: login.uri || currentLoginData.uri, + hostname: login.hostname || currentLoginData.hostname, + username: login.username || currentLoginData.username, + password: login.password || currentLoginData.password, + }; + } + + /** + * Updates the current add new item data with the provided card data. If the + * card data is already present, the data will be merged with the existing data. + * + * @param card - The card data captured from the extension message + */ + private updateCurrentAddNewItemCard(card: NewCardCipherData) { + if (!this.currentAddNewItemData.card) { + this.currentAddNewItemData.card = card; + return; + } + + const currentCardData = this.currentAddNewItemData.card; + this.currentAddNewItemData.card = { + cardholderName: card.cardholderName || currentCardData.cardholderName, + number: card.number || currentCardData.number, + expirationMonth: card.expirationMonth || currentCardData.expirationMonth, + expirationYear: card.expirationYear || currentCardData.expirationYear, + expirationDate: card.expirationDate || currentCardData.expirationDate, + cvv: card.cvv || currentCardData.cvv, + }; + } + + /** + * Updates the current add new item data with the provided identity data. If the + * identity data is already present, the data will be merged with the existing data. + * + * @param identity - The identity data captured from the extension message + */ + private updateCurrentAddNewItemIdentity(identity: NewIdentityCipherData) { + if (!this.currentAddNewItemData.identity) { + this.currentAddNewItemData.identity = identity; + return; + } + + const currentIdentityData = this.currentAddNewItemData.identity; + this.currentAddNewItemData.identity = { + title: identity.title || currentIdentityData.title, + firstName: identity.firstName || currentIdentityData.firstName, + middleName: identity.middleName || currentIdentityData.middleName, + lastName: identity.lastName || currentIdentityData.lastName, + fullName: identity.fullName || currentIdentityData.fullName, + address1: identity.address1 || currentIdentityData.address1, + address2: identity.address2 || currentIdentityData.address2, + address3: identity.address3 || currentIdentityData.address3, + city: identity.city || currentIdentityData.city, + state: identity.state || currentIdentityData.state, + postalCode: identity.postalCode || currentIdentityData.postalCode, + country: identity.country || currentIdentityData.country, + company: identity.company || currentIdentityData.company, + phone: identity.phone || currentIdentityData.phone, + email: identity.email || currentIdentityData.email, + username: identity.username || currentIdentityData.username, + }; + } + + /** + * Handles building a new cipher and opening the add/edit vault item popout. + * + * @param login - The login data captured from the extension message + * @param card - The card data captured from the extension message + * @param identity - The identity data captured from the extension message + * @param sender - The sender of the extension message + */ + private async buildCipherAndOpenAddEditVaultItemPopout({ + login, + card, + identity, + sender, + }: CurrentAddNewItemData) { const cipherView: CipherView = this.buildNewVaultItemCipherView({ - addNewCipherType, login, card, identity, }); - if (cipherView) { + if (!cipherView) { + this.currentAddNewItemData = null; + return; + } + + try { this.closeInlineMenu(sender); await this.cipherService.setAddEditCipherInfo({ cipher: cipherView, @@ -1416,32 +1585,30 @@ export class OverlayBackground implements OverlayBackgroundInterface { await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); + } catch (error) { + this.logService.error("Error building cipher and opening add/edit vault item popout", error); } + + this.currentAddNewItemData = null; } /** * Builds and returns a new cipher view with the provided vault item data. * - * @param addNewCipherType - The type of cipher to add * @param login - The login data captured from the extension message * @param card - The card data captured from the extension message * @param identity - The identity data captured from the extension message */ - private buildNewVaultItemCipherView({ - addNewCipherType, - login, - card, - identity, - }: OverlayAddNewItemMessage) { - if (login && addNewCipherType === CipherType.Login) { + private buildNewVaultItemCipherView({ login, card, identity }: OverlayAddNewItemMessage) { + if (login && this.isAddingNewLogin()) { return this.buildLoginCipherView(login); } - if (card && addNewCipherType === CipherType.Card) { + if (card && this.isAddingNewCard()) { return this.buildCardCipherView(card); } - if (identity && addNewCipherType === CipherType.Identity) { + if (identity && this.isAddingNewIdentity()) { return this.buildIdentityCipherView(identity); } } @@ -1558,8 +1725,16 @@ export class OverlayBackground implements OverlayBackgroundInterface { * Updates the property that identifies if a form field set up for the inline menu is currently focused. * * @param message - The message received from the web page + * @param sender - The sender of the port message */ - private updateIsFieldCurrentlyFocused(message: OverlayBackgroundExtensionMessage) { + private updateIsFieldCurrentlyFocused( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + ) { + if (this.focusedFieldData && !this.senderFrameHasFocusedField(sender)) { + return; + } + this.isFieldCurrentlyFocused = message.isFieldCurrentlyFocused; } @@ -1651,7 +1826,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { return false; } - if (this.focusedFieldData?.frameId === sender.frameId) { + if (this.senderFrameHasFocusedField(sender)) { return true; } @@ -1676,6 +1851,15 @@ export class OverlayBackground implements OverlayBackgroundInterface { return sender.tab.id === this.focusedFieldData?.tabId; } + /** + * Identifies if the sender frame is the same as the focused field's frame. + * + * @param sender - The sender of the message + */ + private senderFrameHasFocusedField(sender: chrome.runtime.MessageSender) { + return sender.frameId === this.focusedFieldData?.frameId; + } + /** * Triggers when a scroll or resize event occurs within a tab. Will reposition the inline menu * if the focused field is within the viewport. @@ -1689,7 +1873,9 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.resetFocusedFieldSubFrameOffsets(sender); this.cancelInlineMenuFadeInAndPositionUpdate(); - void this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender); + this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender).catch((error) => + this.logService.error(error), + ); this.repositionInlineMenuSubject.next(sender); } @@ -1879,14 +2065,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { filledByCipherType: this.focusedFieldData?.filledByCipherType, showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(), }); - void this.updateInlineMenuPosition( + this.updateInlineMenuPosition( { overlayElement: isInlineMenuListPort ? AutofillOverlayElement.List : AutofillOverlayElement.Button, }, port.sender, - ); + ).catch((error) => this.logService.error(error)); }; /** diff --git a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts index 6153a5c926f..96d5e85ca34 100644 --- a/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts +++ b/apps/browser/src/autofill/deprecated/content/autofill-init.deprecated.spec.ts @@ -61,10 +61,13 @@ describe("AutofillInit", () => { autofillInit.init(); jest.advanceTimersByTime(250); - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "bgCollectPageDetails", - sender: "autofillInit", - }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + { + command: "bgCollectPageDetails", + sender: "autofillInit", + }, + expect.any(Function), + ); }); it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html rename to apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.html diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts rename to apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html rename to apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.html diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts rename to apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts index 9c69d76228f..b97c4102fed 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts @@ -9,8 +9,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service"; -import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data"; +import { BrowserFido2UserInterfaceSession } from "../../../vault/fido2/browser-fido2-user-interface.service"; +import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data"; @Component({ selector: "app-fido2-use-browser-link", diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.html b/apps/browser/src/autofill/popup/fido2/fido2.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/fido2/fido2.component.html rename to apps/browser/src/autofill/popup/fido2/fido2.component.html diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts similarity index 97% rename from apps/browser/src/vault/popup/components/fido2/fido2.component.ts rename to apps/browser/src/autofill/popup/fido2/fido2.component.ts index c0389f5afdd..d720a5240f7 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -29,13 +29,13 @@ import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note. import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; -import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service"; +import { ZonedMessageListenerService } from "../../../platform/browser/zoned-message-listener.service"; import { BrowserFido2Message, BrowserFido2UserInterfaceSession, -} from "../../../fido2/browser-fido2-user-interface.service"; -import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service"; -import { VaultPopoutType } from "../../utils/vault-popout-window"; +} from "../../../vault/fido2/browser-fido2-user-interface.service"; +import { VaultPopoutType } from "../../../vault/popup/utils/vault-popout-window"; +import { Fido2UserVerificationService } from "../../../vault/services/fido2-user-verification.service"; interface ViewData { message: BrowserFido2Message; diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 3a297e8d251..22b248be77b 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -76,6 +76,11 @@ export class AutoFillConstants { "textarea", ...AutoFillConstants.ExcludedAutofillTypes, ]; + + static readonly ExcludedIdentityAutocompleteTypes: Set = new Set([ + "current-password", + "new-password", + ]); } export class CreditCardAutoFillConstants { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 6145dbfdc0e..aedb9ae2d84 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -37,10 +37,9 @@ describe("AutofillOverlayContentService", () => { ); autofillInit = new AutofillInit(autofillOverlayContentService); autofillInit.init(); - sendExtensionMessageSpy = jest.spyOn( - autofillOverlayContentService as any, - "sendExtensionMessage", - ); + sendExtensionMessageSpy = jest + .spyOn(autofillOverlayContentService as any, "sendExtensionMessage") + .mockResolvedValue(undefined); Object.defineProperty(document, "readyState", { value: defaultWindowReadyState, writable: true, @@ -1099,7 +1098,9 @@ describe("AutofillOverlayContentService", () => { selectFieldElement.dispatchEvent(new Event("focus")); await flushPromises(); - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu"); + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); }); it("updates the most recently focused field", async () => { @@ -1986,6 +1987,19 @@ describe("AutofillOverlayContentService", () => { expect(autofillFieldFocusSpy).not.toHaveBeenCalled(); expect(nextFocusableElement.focus).toHaveBeenCalled(); }); + + it("focuses the most recently focused input field if no other tabbable elements are found", async () => { + autofillOverlayContentService["focusableElements"] = []; + findTabsSpy.mockReturnValue([]); + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Next }, + }); + await flushPromises(); + + expect(autofillFieldFocusSpy).toHaveBeenCalled(); + }); }); describe("updateAutofillInlineMenuVisibility message handler", () => { 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 0881ecba1ef..064c76b657e 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -249,10 +249,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * to the background script to add a new cipher. */ async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) { - if (!(await this.isInlineMenuListVisible())) { - return; - } - const command = "autofillOverlayAddNewVaultItem"; if (addNewCipherType === CipherType.Login) { @@ -338,7 +334,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ const indexOffset = direction === RedirectFocusDirection.Previous ? -1 : 1; const redirectFocusElement = this.focusableElements[focusedElementIndex + indexOffset]; - redirectFocusElement?.focus(); + if (redirectFocusElement) { + redirectFocusElement.focus(); + return; + } + + this.focusMostRecentlyFocusedField(); } /** @@ -675,7 +676,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } if (elementIsSelectElement(formFieldElement)) { - await this.sendExtensionMessage("closeAutofillInlineMenu"); + await this.sendExtensionMessage("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); return; } @@ -758,7 +761,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ private async updateMostRecentlyFocusedField( formFieldElement: ElementWithOpId, ) { - if (!formFieldElement || !elementIsFillableFormField(formFieldElement)) { + if ( + !formFieldElement || + !elementIsFillableFormField(formFieldElement) || + elementIsSelectElement(formFieldElement) + ) { return; } @@ -1418,8 +1425,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ focusedFieldRectsTop + this.focusedFieldData?.focusedFieldRects?.height; const viewportHeight = globalThis.innerHeight + globalThis.scrollY; return ( - focusedFieldRectsTop && - focusedFieldRectsTop > 0 && + !globalThis.isNaN(focusedFieldRectsTop) && + focusedFieldRectsTop >= 0 && focusedFieldRectsTop < viewportHeight && focusedFieldRectsBottom < viewportHeight ); diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 1ceebe53fca..aca82227284 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -60,7 +60,7 @@ import { GenerateFillScriptOptions, PageDetail, } from "./abstractions/autofill.service"; -import { AutoFillConstants, IdentityAutoFillConstants } from "./autofill-constants"; +import { AutoFillConstants } from "./autofill-constants"; import AutofillService from "./autofill.service"; const mockEquivalentDomains = [ @@ -3056,12 +3056,12 @@ describe("AutofillService", () => { options.cipher.identity = mock(); }); - it("returns null if an identify is not found within the cipher", () => { + it("returns null if an identify is not found within the cipher", async () => { options.cipher.identity = null; jest.spyOn(autofillService as any, "makeScriptAction"); jest.spyOn(autofillService as any, "makeScriptActionWithValue"); - const value = autofillService["generateIdentityFillScript"]( + const value = await autofillService["generateIdentityFillScript"]( fillScript, pageDetails, filledFields, @@ -3087,432 +3087,389 @@ describe("AutofillService", () => { jest.spyOn(autofillService as any, "makeScriptActionWithValue"); }); - it("will not attempt to match custom fields", () => { - const customField = createAutofillFieldMock({ tagName: "span" }); - pageDetails.fields.push(customField); + let isRefactorFeatureFlagSet = false; + for (let index = 0; index < 2; index++) { + describe(`when the isRefactorFeatureFlagSet is ${isRefactorFeatureFlagSet}`, () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(isRefactorFeatureFlagSet); + }); - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); + afterAll(() => { + isRefactorFeatureFlagSet = true; + }); - expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField); - expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled(); - expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); - expect(value.script).toStrictEqual([]); - }); + it("will not attempt to match custom fields", async () => { + const customField = createAutofillFieldMock({ tagName: "span" }); + pageDetails.fields.push(customField); - it("will not attempt to match a field that is of an excluded type", () => { - const excludedField = createAutofillFieldMock({ type: "hidden" }); - pageDetails.fields.push(excludedField); + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField); + expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled(); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); - expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField); - expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalledWith( - excludedField, - AutoFillConstants.ExcludedAutofillTypes, - ); - expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); - expect(value.script).toStrictEqual([]); - }); + it("will not attempt to match a field that is of an excluded type", async () => { + const excludedField = createAutofillFieldMock({ type: "hidden" }); + pageDetails.fields.push(excludedField); - it("will not attempt to match a field that is not viewable", () => { - const viewableField = createAutofillFieldMock({ viewable: false }); - pageDetails.fields.push(viewableField); + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField); + expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalledWith( + excludedField, + AutoFillConstants.ExcludedAutofillTypes, + ); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); - expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(viewableField); - expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled(); - expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); - expect(value.script).toStrictEqual([]); - }); + it("will not attempt to match a field that is not viewable", async () => { + const viewableField = createAutofillFieldMock({ viewable: false }); + pageDetails.fields.push(viewableField); - it("will match a full name field to the vault item identity value", () => { - const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" }); - pageDetails.fields = [fullNameField]; - options.cipher.identity.firstName = firstName; - options.cipher.identity.middleName = middleName; - options.cipher.identity.lastName = lastName; + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(viewableField); + expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled(); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - fullNameField.htmlName, - IdentityAutoFillConstants.FullNameFieldNames, - IdentityAutoFillConstants.FullNameFieldNameValues, - ); - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - `${firstName} ${middleName} ${lastName}`, - fullNameField, - filledFields, - ); - expect(value.script[2]).toStrictEqual([ - "fill_by_opid", - fullNameField.opid, - `${firstName} ${middleName} ${lastName}`, - ]); - }); + it("will match a full name field to the vault item identity value", async () => { + const fullNameField = createAutofillFieldMock({ + opid: "fullName", + htmlName: "full-name", + }); + pageDetails.fields = [fullNameField]; + options.cipher.identity.firstName = firstName; + options.cipher.identity.middleName = middleName; + options.cipher.identity.lastName = lastName; - it("will match a full name field to the a vault item that only has a last name", () => { - const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" }); - pageDetails.fields = [fullNameField]; - options.cipher.identity.firstName = ""; - options.cipher.identity.middleName = ""; - options.cipher.identity.lastName = lastName; + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + `${firstName} ${middleName} ${lastName}`, + fullNameField, + filledFields, + ); + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + fullNameField.opid, + `${firstName} ${middleName} ${lastName}`, + ]); + }); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - fullNameField.htmlName, - IdentityAutoFillConstants.FullNameFieldNames, - IdentityAutoFillConstants.FullNameFieldNameValues, - ); - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - lastName, - fullNameField, - filledFields, - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", fullNameField.opid, lastName]); - }); + it("will match a full name field to the a vault item that only has a last name", async () => { + const fullNameField = createAutofillFieldMock({ + opid: "fullName", + htmlName: "full-name", + }); + pageDetails.fields = [fullNameField]; + options.cipher.identity.firstName = ""; + options.cipher.identity.middleName = ""; + options.cipher.identity.lastName = lastName; - it("will match first name, middle name, and last name fields to the vault item identity value", () => { - const firstNameField = createAutofillFieldMock({ - opid: "firstName", - htmlName: "first-name", + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + lastName, + fullNameField, + filledFields, + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", fullNameField.opid, lastName]); + }); + + it("will match first name, middle name, and last name fields to the vault item identity value", async () => { + const firstNameField = createAutofillFieldMock({ + opid: "firstName", + htmlName: "first-name", + }); + const middleNameField = createAutofillFieldMock({ + opid: "middleName", + htmlName: "middle-name", + }); + const lastNameField = createAutofillFieldMock({ + opid: "lastName", + htmlName: "last-name", + }); + pageDetails.fields = [firstNameField, middleNameField, lastNameField]; + options.cipher.identity.firstName = firstName; + options.cipher.identity.middleName = middleName; + options.cipher.identity.lastName = lastName; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity.firstName, + firstNameField, + filledFields, + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity.middleName, + middleNameField, + filledFields, + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity.lastName, + lastNameField, + filledFields, + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", firstNameField.opid, firstName]); + expect(value.script[5]).toStrictEqual([ + "fill_by_opid", + middleNameField.opid, + middleName, + ]); + expect(value.script[8]).toStrictEqual(["fill_by_opid", lastNameField.opid, lastName]); + }); + + it("will match title and email fields to the vault item identity value", async () => { + const titleField = createAutofillFieldMock({ opid: "title", htmlName: "title" }); + const emailField = createAutofillFieldMock({ opid: "email", htmlName: "email" }); + pageDetails.fields = [titleField, emailField]; + const title = "Mr."; + const email = "email@example.com"; + options.cipher.identity.title = title; + options.cipher.identity.email = email; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity.title, + titleField, + filledFields, + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity.email, + emailField, + filledFields, + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", titleField.opid, title]); + expect(value.script[5]).toStrictEqual(["fill_by_opid", emailField.opid, email]); + }); + + it("will match a full address field to the vault item identity values", async () => { + const fullAddressField = createAutofillFieldMock({ + opid: "fullAddress", + htmlName: "address", + }); + pageDetails.fields = [fullAddressField]; + const address1 = "123 Main St."; + const address2 = "Apt. 1"; + const address3 = "P.O. Box 123"; + options.cipher.identity.address1 = address1; + options.cipher.identity.address2 = address2; + options.cipher.identity.address3 = address3; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + `${address1}, ${address2}, ${address3}`, + fullAddressField, + filledFields, + ); + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + fullAddressField.opid, + `${address1}, ${address2}, ${address3}`, + ]); + }); + + it("will match address1, address2, address3, postalCode, city, state, country, phone, username, and company fields to their corresponding vault item identity values", async () => { + const address1Field = createAutofillFieldMock({ + opid: "address1", + htmlName: "address-1", + }); + const address2Field = createAutofillFieldMock({ + opid: "address2", + htmlName: "address-2", + }); + const address3Field = createAutofillFieldMock({ + opid: "address3", + htmlName: "address-3", + }); + const postalCodeField = createAutofillFieldMock({ + opid: "postalCode", + htmlName: "postal-code", + }); + const cityField = createAutofillFieldMock({ opid: "city", htmlName: "city" }); + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); + const phoneField = createAutofillFieldMock({ opid: "phone", htmlName: "phone" }); + const usernameField = createAutofillFieldMock({ + opid: "username", + htmlName: "username", + }); + const companyField = createAutofillFieldMock({ opid: "company", htmlName: "company" }); + pageDetails.fields = [ + address1Field, + address2Field, + address3Field, + postalCodeField, + cityField, + stateField, + countryField, + phoneField, + usernameField, + companyField, + ]; + const address1 = "123 Main St."; + const address2 = "Apt. 1"; + const address3 = "P.O. Box 123"; + const postalCode = "12345"; + const city = "City"; + const state = "TX"; + const country = "US"; + const phone = "123-456-7890"; + const username = "username"; + const company = "Company"; + options.cipher.identity.address1 = address1; + options.cipher.identity.address2 = address2; + options.cipher.identity.address3 = address3; + options.cipher.identity.postalCode = postalCode; + options.cipher.identity.city = city; + options.cipher.identity.state = state; + options.cipher.identity.country = country; + options.cipher.identity.phone = phone; + options.cipher.identity.username = username; + options.cipher.identity.company = company; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(value.script).toContainEqual(["fill_by_opid", address1Field.opid, address1]); + expect(value.script).toContainEqual(["fill_by_opid", address2Field.opid, address2]); + expect(value.script).toContainEqual(["fill_by_opid", address3Field.opid, address3]); + expect(value.script).toContainEqual(["fill_by_opid", postalCodeField.opid, postalCode]); + expect(value.script).toContainEqual(["fill_by_opid", cityField.opid, city]); + expect(value.script).toContainEqual(["fill_by_opid", stateField.opid, state]); + expect(value.script).toContainEqual(["fill_by_opid", countryField.opid, country]); + expect(value.script).toContainEqual(["fill_by_opid", phoneField.opid, phone]); + expect(value.script).toContainEqual(["fill_by_opid", usernameField.opid, username]); + expect(value.script).toContainEqual(["fill_by_opid", companyField.opid, company]); + }); + + it("will find the two character IsoState value for an identity cipher that contains the full name of a state", async () => { + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + pageDetails.fields = [stateField]; + const state = "California"; + options.cipher.identity.state = state; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "CA", + expect.anything(), + expect.anything(), + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "CA"]); + }); + + it("will find the two character IsoProvince value for an identity cipher that contains the full name of a province", async () => { + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + pageDetails.fields = [stateField]; + const state = "Ontario"; + options.cipher.identity.state = state; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "ON", + expect.anything(), + expect.anything(), + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "ON"]); + }); + + it("will find the two character IsoCountry value for an identity cipher that contains the full name of a country", async () => { + const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); + pageDetails.fields = [countryField]; + const country = "Somalia"; + options.cipher.identity.country = country; + + const value = await autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "SO", + expect.anything(), + expect.anything(), + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", countryField.opid, "SO"]); + }); }); - const middleNameField = createAutofillFieldMock({ - opid: "middleName", - htmlName: "middle-name", - }); - const lastNameField = createAutofillFieldMock({ opid: "lastName", htmlName: "last-name" }); - pageDetails.fields = [firstNameField, middleNameField, lastNameField]; - options.cipher.identity.firstName = firstName; - options.cipher.identity.middleName = middleName; - options.cipher.identity.lastName = lastName; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - firstNameField.htmlName, - IdentityAutoFillConstants.FirstnameFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - middleNameField.htmlName, - IdentityAutoFillConstants.MiddlenameFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - lastNameField.htmlName, - IdentityAutoFillConstants.LastnameFieldNames, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( - fillScript, - options.cipher.identity, - expect.anything(), - filledFields, - firstNameField.opid, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( - fillScript, - options.cipher.identity, - expect.anything(), - filledFields, - middleNameField.opid, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( - fillScript, - options.cipher.identity, - expect.anything(), - filledFields, - lastNameField.opid, - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", firstNameField.opid, firstName]); - expect(value.script[5]).toStrictEqual(["fill_by_opid", middleNameField.opid, middleName]); - expect(value.script[8]).toStrictEqual(["fill_by_opid", lastNameField.opid, lastName]); - }); - - it("will match title and email fields to the vault item identity value", () => { - const titleField = createAutofillFieldMock({ opid: "title", htmlName: "title" }); - const emailField = createAutofillFieldMock({ opid: "email", htmlName: "email" }); - pageDetails.fields = [titleField, emailField]; - const title = "Mr."; - const email = "email@example.com"; - options.cipher.identity.title = title; - options.cipher.identity.email = email; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - titleField.htmlName, - IdentityAutoFillConstants.TitleFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - emailField.htmlName, - IdentityAutoFillConstants.EmailFieldNames, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( - fillScript, - options.cipher.identity, - expect.anything(), - filledFields, - titleField.opid, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( - fillScript, - options.cipher.identity, - expect.anything(), - filledFields, - emailField.opid, - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", titleField.opid, title]); - expect(value.script[5]).toStrictEqual(["fill_by_opid", emailField.opid, email]); - }); - - it("will match a full address field to the vault item identity values", () => { - const fullAddressField = createAutofillFieldMock({ - opid: "fullAddress", - htmlName: "address", - }); - pageDetails.fields = [fullAddressField]; - const address1 = "123 Main St."; - const address2 = "Apt. 1"; - const address3 = "P.O. Box 123"; - options.cipher.identity.address1 = address1; - options.cipher.identity.address2 = address2; - options.cipher.identity.address3 = address3; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - fullAddressField.htmlName, - IdentityAutoFillConstants.AddressFieldNames, - IdentityAutoFillConstants.AddressFieldNameValues, - ); - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - `${address1}, ${address2}, ${address3}`, - fullAddressField, - filledFields, - ); - expect(value.script[2]).toStrictEqual([ - "fill_by_opid", - fullAddressField.opid, - `${address1}, ${address2}, ${address3}`, - ]); - }); - - it("will match address1, address2, address3, postalCode, city, state, country, phone, username, and company fields to their corresponding vault item identity values", () => { - const address1Field = createAutofillFieldMock({ opid: "address1", htmlName: "address-1" }); - const address2Field = createAutofillFieldMock({ opid: "address2", htmlName: "address-2" }); - const address3Field = createAutofillFieldMock({ opid: "address3", htmlName: "address-3" }); - const postalCodeField = createAutofillFieldMock({ - opid: "postalCode", - htmlName: "postal-code", - }); - const cityField = createAutofillFieldMock({ opid: "city", htmlName: "city" }); - const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); - const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); - const phoneField = createAutofillFieldMock({ opid: "phone", htmlName: "phone" }); - const usernameField = createAutofillFieldMock({ opid: "username", htmlName: "username" }); - const companyField = createAutofillFieldMock({ opid: "company", htmlName: "company" }); - pageDetails.fields = [ - address1Field, - address2Field, - address3Field, - postalCodeField, - cityField, - stateField, - countryField, - phoneField, - usernameField, - companyField, - ]; - const address1 = "123 Main St."; - const address2 = "Apt. 1"; - const address3 = "P.O. Box 123"; - const postalCode = "12345"; - const city = "City"; - const state = "State"; - const country = "Country"; - const phone = "123-456-7890"; - const username = "username"; - const company = "Company"; - options.cipher.identity.address1 = address1; - options.cipher.identity.address2 = address2; - options.cipher.identity.address3 = address3; - options.cipher.identity.postalCode = postalCode; - options.cipher.identity.city = city; - options.cipher.identity.state = state; - options.cipher.identity.country = country; - options.cipher.identity.phone = phone; - options.cipher.identity.username = username; - options.cipher.identity.company = company; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - address1Field.htmlName, - IdentityAutoFillConstants.Address1FieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - address2Field.htmlName, - IdentityAutoFillConstants.Address2FieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - address3Field.htmlName, - IdentityAutoFillConstants.Address3FieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - postalCodeField.htmlName, - IdentityAutoFillConstants.PostalCodeFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - cityField.htmlName, - IdentityAutoFillConstants.CityFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - stateField.htmlName, - IdentityAutoFillConstants.StateFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - countryField.htmlName, - IdentityAutoFillConstants.CountryFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - phoneField.htmlName, - IdentityAutoFillConstants.PhoneFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - usernameField.htmlName, - IdentityAutoFillConstants.UserNameFieldNames, - ); - expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( - companyField.htmlName, - IdentityAutoFillConstants.CompanyFieldNames, - ); - expect(autofillService["makeScriptAction"]).toHaveBeenCalled(); - expect(value.script[2]).toStrictEqual(["fill_by_opid", address1Field.opid, address1]); - expect(value.script[5]).toStrictEqual(["fill_by_opid", address2Field.opid, address2]); - expect(value.script[8]).toStrictEqual(["fill_by_opid", address3Field.opid, address3]); - expect(value.script[11]).toStrictEqual(["fill_by_opid", cityField.opid, city]); - expect(value.script[14]).toStrictEqual(["fill_by_opid", postalCodeField.opid, postalCode]); - expect(value.script[17]).toStrictEqual(["fill_by_opid", companyField.opid, company]); - expect(value.script[20]).toStrictEqual(["fill_by_opid", phoneField.opid, phone]); - expect(value.script[23]).toStrictEqual(["fill_by_opid", usernameField.opid, username]); - expect(value.script[26]).toStrictEqual(["fill_by_opid", stateField.opid, state]); - expect(value.script[29]).toStrictEqual(["fill_by_opid", countryField.opid, country]); - }); - - it("will find the two character IsoState value for an identity cipher that contains the full name of a state", () => { - const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); - pageDetails.fields = [stateField]; - const state = "California"; - options.cipher.identity.state = state; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - "CA", - expect.anything(), - expect.anything(), - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "CA"]); - }); - - it("will find the two character IsoProvince value for an identity cipher that contains the full name of a province", () => { - const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); - pageDetails.fields = [stateField]; - const state = "Ontario"; - options.cipher.identity.state = state; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - "ON", - expect.anything(), - expect.anything(), - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "ON"]); - }); - - it("will find the two character IsoCountry value for an identity cipher that contains the full name of a country", () => { - const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); - pageDetails.fields = [countryField]; - const country = "Somalia"; - options.cipher.identity.country = country; - - const value = autofillService["generateIdentityFillScript"]( - fillScript, - pageDetails, - filledFields, - options, - ); - - expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( - fillScript, - "SO", - expect.anything(), - expect.anything(), - ); - expect(value.script[2]).toStrictEqual(["fill_by_opid", countryField.opid, "SO"]); - }); + } }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index f2ef9790f62..d9ae4e99237 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -26,6 +26,7 @@ import { FieldType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; @@ -478,6 +479,12 @@ export default class AutofillService implements AutofillServiceInterface { return totpCode; } + /** + * Checks if the cipher requires password reprompt and opens the password reprompt popout if necessary. + * + * @param cipher - The cipher to autofill + * @param tab - The tab to autofill + */ async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise { const userHasMasterPasswordAndKeyHash = await this.userVerificationService.hasMasterPasswordAndMasterKeyHash(); @@ -654,7 +661,7 @@ export default class AutofillService implements AutofillServiceInterface { fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); break; case CipherType.Identity: - fillScript = this.generateIdentityFillScript( + fillScript = await this.generateIdentityFillScript( fillScript, pageDetails, filledFields, @@ -1243,12 +1250,16 @@ export default class AutofillService implements AutofillServiceInterface { * @returns {AutofillScript} * @private */ - private generateIdentityFillScript( + private async generateIdentityFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, filledFields: { [id: string]: AutofillField }, options: GenerateFillScriptOptions, - ): AutofillScript { + ): Promise { + if (await this.configService.getFeatureFlag(FeatureFlag.GenerateIdentityFillScriptRefactor)) { + return this._generateIdentityFillScript(fillScript, pageDetails, filledFields, options); + } + if (!options.cipher.identity) { return null; } @@ -1476,6 +1487,589 @@ export default class AutofillService implements AutofillServiceInterface { return fillScript; } + /** + * Generates the autofill script for the specified page details and identity cipher item. + * + * @param fillScript - Object to store autofill script, passed between method references + * @param pageDetails - The details of the page to autofill + * @param filledFields - The fields that have already been filled, passed between method references + * @param options - Contains data used to fill cipher items + */ + private _generateIdentityFillScript( + fillScript: AutofillScript, + pageDetails: AutofillPageDetails, + filledFields: { [id: string]: AutofillField }, + options: GenerateFillScriptOptions, + ): AutofillScript { + const identity = options.cipher.identity; + if (!identity) { + return null; + } + + for (let fieldsIndex = 0; fieldsIndex < pageDetails.fields.length; fieldsIndex++) { + const field = pageDetails.fields[fieldsIndex]; + if (this.excludeFieldFromIdentityFill(field)) { + continue; + } + + const keywordsList = this.getIdentityAutofillFieldKeywords(field); + const keywordsCombined = keywordsList.join(","); + if (this.shouldMakeIdentityTitleFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.title, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityNameFillScript(filledFields, keywordsList)) { + this.makeIdentityNameFillScript(fillScript, filledFields, field, identity); + continue; + } + + if (this.shouldMakeIdentityFirstNameFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.firstName, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityMiddleNameFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.middleName, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityLastNameFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.lastName, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityEmailFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.email, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityAddressFillScript(filledFields, keywordsList)) { + this.makeIdentityAddressFillScript(fillScript, filledFields, field, identity); + continue; + } + + if (this.shouldMakeIdentityAddress1FillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.address1, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityAddress2FillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.address2, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityAddress3FillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.address3, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityPostalCodeFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.postalCode, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityCityFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.city, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityStateFillScript(filledFields, keywordsCombined)) { + this.makeIdentityStateFillScript(fillScript, filledFields, field, identity); + continue; + } + + if (this.shouldMakeIdentityCountryFillScript(filledFields, keywordsCombined)) { + this.makeIdentityCountryFillScript(fillScript, filledFields, field, identity); + continue; + } + + if (this.shouldMakeIdentityPhoneFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.phone, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityUserNameFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.username, field, filledFields); + continue; + } + + if (this.shouldMakeIdentityCompanyFillScript(filledFields, keywordsCombined)) { + this.makeScriptActionWithValue(fillScript, identity.company, field, filledFields); + } + } + + return fillScript; + } + + /** + * Identifies if the current field should be excluded from triggering autofill of the identity cipher. + * + * @param field - The field to check + */ + private excludeFieldFromIdentityFill(field: AutofillField): boolean { + return ( + AutofillService.isExcludedFieldType(field, AutoFillConstants.ExcludedAutofillTypes) || + AutoFillConstants.ExcludedIdentityAutocompleteTypes.has(field.autoCompleteType) || + !field.viewable + ); + } + + /** + * Gathers all unique keyword identifiers from a field that can be used to determine what + * identity value should be filled. + * + * @param field - The field to gather keywords from + */ + private getIdentityAutofillFieldKeywords(field: AutofillField): string[] { + const keywords: Set = new Set(); + for (let index = 0; index < IdentityAutoFillConstants.IdentityAttributes.length; index++) { + const attribute = IdentityAutoFillConstants.IdentityAttributes[index]; + if (field[attribute]) { + keywords.add( + field[attribute] + .trim() + .toLowerCase() + .replace(/[^a-zA-Z0-9]+/g, ""), + ); + } + } + + return Array.from(keywords); + } + + /** + * Identifies if a fill script action for the identity title + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityTitleFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.title && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.TitleFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity name + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityNameFillScript( + filledFields: Record, + keywords: string[], + ): boolean { + return ( + !filledFields.name && + keywords.some((keyword) => + AutofillService.isFieldMatch( + keyword, + IdentityAutoFillConstants.FullNameFieldNames, + IdentityAutoFillConstants.FullNameFieldNameValues, + ), + ) + ); + } + + /** + * Identifies if a fill script action for the identity first name + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityFirstNameFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.firstName && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.FirstnameFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity middle name + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityMiddleNameFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.middleName && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.MiddlenameFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity last name + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityLastNameFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.lastName && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.LastnameFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity email + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityEmailFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.email && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.EmailFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity address + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityAddressFillScript( + filledFields: Record, + keywords: string[], + ): boolean { + return ( + !filledFields.address && + keywords.some((keyword) => + AutofillService.isFieldMatch( + keyword, + IdentityAutoFillConstants.AddressFieldNames, + IdentityAutoFillConstants.AddressFieldNameValues, + ), + ) + ); + } + + /** + * Identifies if a fill script action for the identity address1 + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityAddress1FillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.address1 && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address1FieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity address2 + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityAddress2FillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.address2 && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address2FieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity address3 + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityAddress3FillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.address3 && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address3FieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity postal code + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityPostalCodeFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.postalCode && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.PostalCodeFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity city + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityCityFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.city && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CityFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity state + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityStateFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.state && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.StateFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity country + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityCountryFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.country && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CountryFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity phone + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityPhoneFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.phone && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.PhoneFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity username + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityUserNameFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.username && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.UserNameFieldNames) + ); + } + + /** + * Identifies if a fill script action for the identity company + * field should be created for the provided field. + * + * @param filledFields - The fields that have already been filled + * @param keywords - The keywords from the field + */ + private shouldMakeIdentityCompanyFillScript( + filledFields: Record, + keywords: string, + ): boolean { + return ( + !filledFields.company && + AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CompanyFieldNames) + ); + } + + /** + * Creates an identity name fill script action for the provided field. This is used + * when filling a `full name` field, using the first, middle, and last name from the + * identity cipher item. + * + * @param fillScript - The autofill script to add the action to + * @param filledFields - The fields that have already been filled + * @param field - The field to fill + * @param identity - The identity cipher item + */ + private makeIdentityNameFillScript( + fillScript: AutofillScript, + filledFields: Record, + field: AutofillField, + identity: IdentityView, + ) { + let name = ""; + if (identity.firstName) { + name += identity.firstName; + } + + if (identity.middleName) { + name += !name ? identity.middleName : ` ${identity.middleName}`; + } + + if (identity.lastName) { + name += !name ? identity.lastName : ` ${identity.lastName}`; + } + + this.makeScriptActionWithValue(fillScript, name, field, filledFields); + } + + /** + * Creates an identity address fill script action for the provided field. This is used + * when filling a generic `address` field, using the address1, address2, and address3 + * from the identity cipher item. + * + * @param fillScript - The autofill script to add the action to + * @param filledFields - The fields that have already been filled + * @param field - The field to fill + * @param identity - The identity cipher item + */ + private makeIdentityAddressFillScript( + fillScript: AutofillScript, + filledFields: Record, + field: AutofillField, + identity: IdentityView, + ) { + if (!identity.address1) { + return; + } + + let address = identity.address1; + + if (identity.address2) { + address += `, ${identity.address2}`; + } + + if (identity.address3) { + address += `, ${identity.address3}`; + } + + this.makeScriptActionWithValue(fillScript, address, field, filledFields); + } + + /** + * Creates an identity state fill script action for the provided field. This is used + * when filling a `state` field, using the state value from the identity cipher item. + * If the state value is a full name, it will be converted to an ISO code. + * + * @param fillScript - The autofill script to add the action to + * @param filledFields - The fields that have already been filled + * @param field - The field to fill + * @param identity - The identity cipher item + */ + private makeIdentityStateFillScript( + fillScript: AutofillScript, + filledFields: Record, + field: AutofillField, + identity: IdentityView, + ) { + if (!identity.state) { + return; + } + + if (identity.state.length <= 2) { + this.makeScriptActionWithValue(fillScript, identity.state, field, filledFields); + return; + } + + const stateLower = identity.state.toLowerCase(); + const isoState = + IdentityAutoFillConstants.IsoStates[stateLower] || + IdentityAutoFillConstants.IsoProvinces[stateLower]; + if (isoState) { + this.makeScriptActionWithValue(fillScript, isoState, field, filledFields); + } + } + + /** + * Creates an identity country fill script action for the provided field. This is used + * when filling a `country` field, using the country value from the identity cipher item. + * If the country value is a full name, it will be converted to an ISO code. + * + * @param fillScript - The autofill script to add the action to + * @param filledFields - The fields that have already been filled + * @param field - The field to fill + * @param identity - The identity cipher item + */ + private makeIdentityCountryFillScript( + fillScript: AutofillScript, + filledFields: Record, + field: AutofillField, + identity: IdentityView, + ) { + if (!identity.country) { + return; + } + + if (identity.country.length <= 2) { + this.makeScriptActionWithValue(fillScript, identity.country, field, filledFields); + return; + } + + const countryLower = identity.country.toLowerCase(); + const isoCountry = IdentityAutoFillConstants.IsoCountries[countryLower]; + if (isoCountry) { + this.makeScriptActionWithValue(fillScript, isoCountry, field, filledFields); + } + } + /** * Accepts an HTMLInputElement type value and a list of * excluded types and returns true if the type is excluded. diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts index feb78563db9..9b3ef4ce692 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts @@ -16,10 +16,8 @@ describe("InlineMenuFieldQualificationService", () => { forms: {}, fields: [], }); - chrome.runtime.sendMessage = jest.fn().mockImplementation((message) => ({ - result: message.command === "getInlineMenuFieldQualificationFeatureFlag", - })); inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true; }); describe("isFieldForLoginForm", () => { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 3c48d8db83f..955334e3fa0 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -209,10 +209,7 @@ export class InlineMenuFieldQualificationService return false; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, this.creditCardFieldKeywords) - ); + return this.keywordsFoundInFieldData(field, this.creditCardFieldKeywords); } // If the field has a parent form, check the fields from that form exclusively @@ -232,10 +229,7 @@ export class InlineMenuFieldQualificationService return false; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords]) - ); + return this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords]); } /** Validates the provided field as a field for an account creation form. @@ -264,10 +258,7 @@ export class InlineMenuFieldQualificationService // If no password fields are found on the page, check for keywords that indicate the field is // part of an account creation form. - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords) - ); + return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords); } // If the field has a parent form, check the fields from that form exclusively @@ -277,10 +268,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords) - ); + return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords); } /** @@ -480,9 +468,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardHolderFieldNames, false) + return this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.CardHolderFieldNames, + false, ); }; @@ -496,9 +485,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardNumberFieldNames, false) + return this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.CardNumberFieldNames, + false, ); }; @@ -514,9 +504,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardExpiryFieldNames, false) + return this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.CardExpiryFieldNames, + false, ); }; @@ -532,9 +523,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryMonthFieldNames, false) + return this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.ExpiryMonthFieldNames, + false, ); }; @@ -550,9 +542,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryYearFieldNames, false) + return this.keywordsFoundInFieldData( + field, + CreditCardAutoFillConstants.ExpiryYearFieldNames, + false, ); }; @@ -566,10 +559,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false); }; /** @@ -584,10 +574,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.TitleFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.TitleFieldNames, false); }; /** @@ -600,9 +587,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.FirstnameFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.FirstnameFieldNames, + false, ); }; @@ -616,9 +604,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.MiddlenameFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.MiddlenameFieldNames, + false, ); }; @@ -632,9 +621,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.LastnameFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.LastnameFieldNames, + false, ); }; @@ -648,9 +638,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.FullNameFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.FullNameFieldNames, + false, ); }; @@ -664,16 +655,13 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData( - field, - [ - ...IdentityAutoFillConstants.AddressFieldNames, - ...IdentityAutoFillConstants.Address1FieldNames, - ], - false, - ) + return this.keywordsFoundInFieldData( + field, + [ + ...IdentityAutoFillConstants.AddressFieldNames, + ...IdentityAutoFillConstants.Address1FieldNames, + ], + false, ); }; @@ -687,9 +675,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.Address2FieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.Address2FieldNames, + false, ); }; @@ -703,9 +692,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.Address3FieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.Address3FieldNames, + false, ); }; @@ -719,10 +709,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CityFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CityFieldNames, false); }; /** @@ -735,10 +722,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false); }; /** @@ -751,9 +735,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PostalCodeFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.PostalCodeFieldNames, + false, ); }; @@ -767,10 +752,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CountryFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CountryFieldNames, false); }; /** @@ -783,10 +765,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CompanyFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CompanyFieldNames, false); }; /** @@ -799,10 +778,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PhoneFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PhoneFieldNames, false); }; /** @@ -818,10 +794,7 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.EmailFieldNames, false) - ); + return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.EmailFieldNames, false); }; /** @@ -834,9 +807,10 @@ export class InlineMenuFieldQualificationService return true; } - return ( - !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) && - this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.UserNameFieldNames, false) + return this.keywordsFoundInFieldData( + field, + IdentityAutoFillConstants.UserNameFieldNames, + false, ); }; @@ -1039,11 +1013,13 @@ export class InlineMenuFieldQualificationService fuzzyMatchKeywords = true, ) { const searchedValues = this.getAutofillFieldDataKeywords(autofillFieldData, fuzzyMatchKeywords); + const parsedKeywords = keywords.map((keyword) => keyword.replace(/-/g, "")); + if (typeof searchedValues === "string") { - return keywords.some((keyword) => searchedValues.indexOf(keyword) > -1); + return parsedKeywords.some((keyword) => searchedValues.indexOf(keyword) > -1); } - return keywords.some((keyword) => searchedValues.has(keyword)); + return parsedKeywords.some((keyword) => searchedValues.has(keyword)); } /** @@ -1072,8 +1048,19 @@ export class InlineMenuFieldQualificationService autofillFieldData["label-tag"], autofillFieldData["label-top"], ]; - const keywordsSet = new Set(keywords); - const stringValue = keywords.join(",").toLowerCase(); + const keywordsSet = new Set(); + for (let i = 0; i < keywords.length; i++) { + if (typeof keywords[i] === "string") { + keywords[i] + .toLowerCase() + .replace(/-/g, "") + .replace(/[^a-zA-Z0-9]+/g, "|") + .split("|") + .forEach((keyword) => keywordsSet.add(keyword)); + } + } + + const stringValue = Array.from(keywordsSet).join(","); this.autofillFieldKeywordsMap.set(autofillFieldData, { keywordsSet, stringValue }); } diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 47ab6183954..36d22ed0cd3 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -38,14 +38,24 @@ describe("generateRandomCustomElementName", () => { describe("sendExtensionMessage", () => { it("sends a message to the extension", async () => { - chrome.runtime.sendMessage = jest.fn().mockResolvedValue("sendMessageResponse"); + const extensionMessagePromise = sendExtensionMessage("some-extension-message"); - const response = await sendExtensionMessage("some-extension-message", { value: "value" }); + // Jest doesn't give anyway to select the typed overload of "sendMessage", + // a cast is needed to get the correct spy type. + const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage") as unknown as jest.SpyInstance< + void, + [message: string, responseCallback: (response: string) => void], + unknown + >; - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "some-extension-message", - value: "value", - }); + expect(sendMessageSpy).toHaveBeenCalled(); + + const [latestCall] = sendMessageSpy.mock.calls; + const responseCallback = latestCall[1]; + + responseCallback("sendMessageResponse"); + + const response = await extensionMessagePromise; expect(response).toEqual("sendMessageResponse"); }); }); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index bc486820642..95e84d19d12 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -105,7 +105,19 @@ export async function sendExtensionMessage( command: string, options: Record = {}, ): Promise { - return chrome.runtime.sendMessage({ command, ...options }); + if (typeof browser !== "undefined") { + return browser.runtime.sendMessage({ command, ...options }); + } + + return new Promise((resolve) => + chrome.runtime.sendMessage(Object.assign({ command }, options), (response) => { + if (chrome.runtime.lastError) { + resolve(null); + } + + resolve(response); + }), + ); } /** diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 70978f40704..d514c417efd 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -452,6 +452,9 @@ export default class MainBackground { return new ForegroundMemoryStorageService(); } + // For local backed session storage, we expect that the encrypted data on disk will persist longer than the encryption key in memory + // and failures to decrypt because of that are completely expected. For this reason, we pass in `false` to the `EncryptServiceImplementation` + // so that MAC failures are not logged. return new LocalBackedSessionStorageService( sessionKey, this.storageService, @@ -849,6 +852,7 @@ export default class MainBackground { this.sendService, this.sendApiService, messageListener, + this.stateProvider, ); } else { this.syncService = new DefaultSyncService( @@ -876,6 +880,7 @@ export default class MainBackground { this.billingAccountProfileStateService, this.tokenService, this.authService, + this.stateProvider, ); this.syncServiceListener = new SyncServiceListener( @@ -1047,6 +1052,7 @@ export default class MainBackground { this.logService, this.authService, this.biometricStateService, + this.accountService, ); this.commandsBackground = new CommandsBackground( this, @@ -1358,7 +1364,6 @@ export default class MainBackground { ); await Promise.all([ - this.syncService.setLastSync(new Date(0), userBeingLoggedOut), this.cryptoService.clearKeys(userBeingLoggedOut), this.cipherService.clear(userBeingLoggedOut), this.folderService.clear(userBeingLoggedOut), diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 3fb943f613b..e19485c7118 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,5 +1,6 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -81,6 +82,7 @@ export class NativeMessagingBackground { private logService: LogService, private authService: AuthService, private biometricStateService: BiometricStateService, + private accountService: AccountService, ) { if (chrome?.permissions?.onAdded) { // Reload extension to activate nativeMessaging @@ -223,6 +225,16 @@ export class NativeMessagingBackground { }); } + showIncorrectUserKeyDialog() { + this.messagingService.send("showDialog", { + title: { key: "nativeMessagingWrongUserKeyTitle" }, + content: { key: "nativeMessagingWrongUserKeyDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + } + async send(message: Message) { if (!this.connected) { await this.connect(); @@ -350,7 +362,26 @@ export class NativeMessagingBackground { const userKey = new SymmetricCryptoKey( Utils.fromB64ToArray(message.userKeyB64), ) as UserKey; - await this.cryptoService.setUserKey(userKey); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const isUserKeyValid = await this.cryptoService.validateUserKey( + userKey, + activeUserId, + ); + if (isUserKeyValid) { + await this.cryptoService.setUserKey(userKey, activeUserId); + } else { + this.logService.error("Unable to verify biometric unlocked userkey"); + await this.cryptoService.clearKeys(activeUserId); + this.showIncorrectUserKeyDialog(); + + // Exit early + if (this.resolver) { + this.resolver(message); + } + return; + } } else { throw new Error("No key received"); } @@ -371,21 +402,6 @@ export class NativeMessagingBackground { return; } - // Verify key is correct by attempting to decrypt a secret - try { - await this.cryptoService.getFingerprint(await this.stateService.getUserId()); - } catch (e) { - this.logService.error("Unable to verify key: " + e); - await this.cryptoService.clearKeys(); - this.showWrongUserDialog(); - - // Exit early - if (this.resolver) { - this.resolver(message); - } - return; - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.runtimeBackground.processMessage({ command: "unlocked" }); diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 047687e09ff..8428a74d430 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -278,12 +278,24 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic async supportsBiometric() { const platformInfo = await BrowserApi.getPlatformInfo(); - if (platformInfo.os === "mac" || platformInfo.os === "win") { + if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") { return true; } return false; } + async biometricsNeedsSetup(): Promise { + return false; + } + + async biometricsSupportsAutoSetup(): Promise { + return false; + } + + async biometricsSetup(): Promise { + return; + } + authenticateBiometric() { return this.biometricCallback(); } diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index a9ee7c23b9c..365ce6a83ca 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -7,8 +7,11 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -18,6 +21,7 @@ import { DO_FULL_SYNC, ForegroundSyncService, FullSyncMessage } from "./foregrou import { FullSyncFinishedMessage } from "./sync-service.listener"; describe("ForegroundSyncService", () => { + const userId = Utils.newGuid() as UserId; const stateService = mock(); const folderService = mock(); const folderApiService = mock(); @@ -31,6 +35,7 @@ describe("ForegroundSyncService", () => { const sendService = mock(); const sendApiService = mock(); const messageListener = mock(); + const stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); const sut = new ForegroundSyncService( stateService, @@ -46,6 +51,7 @@ describe("ForegroundSyncService", () => { sendService, sendApiService, messageListener, + stateProvider, ); beforeEach(() => { diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index 0a2c7074298..23c0e1ff9f9 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -11,6 +11,7 @@ import { MessageSender, } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { CoreSyncService } from "@bitwarden/common/platform/sync/internal"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -40,6 +41,7 @@ export class ForegroundSyncService extends CoreSyncService { sendService: InternalSendService, sendApiService: SendApiService, private readonly messageListener: MessageListener, + stateProvider: StateProvider, ) { super( stateService, @@ -54,6 +56,7 @@ export class ForegroundSyncService extends CoreSyncService { authService, sendService, sendApiService, + stateProvider, ); } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 47d451cc016..e556e459287 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -39,6 +39,7 @@ import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component" import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; +import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component"; @@ -63,7 +64,6 @@ import { ImportBrowserV2Component } from "../tools/popup/settings/import/import- import { ImportBrowserComponent } from "../tools/popup/settings/import/import-browser.component"; import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component"; import { SettingsComponent } from "../tools/popup/settings/settings.component"; -import { Fido2Component } from "../vault/popup/components/fido2/fido2.component"; import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component"; import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component"; import { CollectionsComponent } from "../vault/popup/components/vault/collections.component"; diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 2165cf6fce6..56ddd3c6ba3 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -35,6 +35,9 @@ import { SsoComponent } from "../auth/popup/sso.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; +import { Fido2CipherRowComponent } from "../autofill/popup/fido2/fido2-cipher-row.component"; +import { Fido2UseBrowserLinkComponent } from "../autofill/popup/fido2/fido2-use-browser-link.component"; +import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component"; @@ -58,9 +61,6 @@ import { SendTypeComponent } from "../tools/popup/send/send-type.component"; import { SettingsComponent } from "../tools/popup/settings/settings.component"; import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component"; import { CipherRowComponent } from "../vault/popup/components/cipher-row.component"; -import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component"; -import { Fido2UseBrowserLinkComponent } from "../vault/popup/components/fido2/fido2-use-browser-link.component"; -import { Fido2Component } from "../vault/popup/components/fido2/fido2.component"; import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component"; import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component"; import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 2c7129db293..2fba41d17ad 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -15,6 +15,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -82,6 +83,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; +import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import MainBackground from "../../background/main.background"; @@ -521,6 +523,11 @@ const safeProviders: SafeProvider[] = [ useFactory: getBgService("taskSchedulerService"), deps: [], }), + safeProvider({ + provide: AnonLayoutWrapperDataService, + useClass: ExtensionAnonLayoutWrapperDataService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 52f7c3ed8ff..a8dd3e24f29 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -8,12 +8,32 @@ -
+
{{ "sendsNoItemsTitle" | i18n }} {{ "sendsNoItemsMessage" | i18n }}
- + + +
+ + {{ "noItemsMatchSearch" | i18n }} + {{ "clearFiltersOrTryAnother" | i18n }} + +
+ +
+ +
+ + +
diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index d7a302b7903..53a0441eecd 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -1,11 +1,12 @@ import { CommonModule } from "@angular/common"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { RouterLink } from "@angular/router"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { RouterTestingModule } from "@angular/router/testing"; -import { mock } from "jest-mock-extended"; -import { Observable, of } from "rxjs"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of, BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -15,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -22,7 +24,10 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { NewSendDropdownComponent, SendListItemsContainerComponent, + SendItemsService, + SendSearchComponent, SendListFiltersComponent, + SendListFiltersService, } from "@bitwarden/send-ui"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; @@ -30,31 +35,49 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { SendV2Component } from "./send-v2.component"; +import { SendV2Component, SendState } from "./send-v2.component"; describe("SendV2Component", () => { let component: SendV2Component; let fixture: ComponentFixture; - let sendViews$: Observable; + let sendItemsService: MockProxy; + let sendListFiltersService: SendListFiltersService; + let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>; + let sendItemsServiceEmptyList$: BehaviorSubject; + let sendItemsServiceNoFilteredResults$: BehaviorSubject; beforeEach(async () => { - sendViews$ = of([ - { id: "1", name: "Send A" }, - { id: "2", name: "Send B" }, - ] as SendView[]); + sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null }); + sendItemsServiceEmptyList$ = new BehaviorSubject(false); + sendItemsServiceNoFilteredResults$ = new BehaviorSubject(false); + + sendItemsService = mock({ + filteredAndSortedSends$: of([ + { id: "1", name: "Send A" }, + { id: "2", name: "Send B" }, + ] as SendView[]), + latestSearchText$: of(""), + }); + + sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder()); + + sendListFiltersService.filters$ = sendListFiltersServiceFilters$; + sendItemsService.emptyList$ = sendItemsServiceEmptyList$; + sendItemsService.noFilteredResults$ = sendItemsServiceNoFilteredResults$; await TestBed.configureTestingModule({ imports: [ CommonModule, RouterTestingModule, JslibModule, - NoItemsModule, + ReactiveFormsModule, ButtonModule, NoItemsModule, - RouterLink, NewSendDropdownComponent, SendListItemsContainerComponent, SendListFiltersComponent, + SendSearchComponent, + SendV2Component, PopupPageComponent, PopupHeaderComponent, PopOutComponent, @@ -66,21 +89,24 @@ describe("SendV2Component", () => { { provide: AvatarService, useValue: mock() }, { provide: BillingAccountProfileStateService, - useValue: mock(), + useValue: { hasPremiumFromAnySource$: of(false) }, }, { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, { provide: SendApiService, useValue: mock() }, - { provide: SendService, useValue: { sendViews$ } }, + { provide: SendItemsService, useValue: mock() }, + { provide: SearchService, useValue: mock() }, + { provide: SendService, useValue: { sendViews$: new BehaviorSubject([]) } }, + { provide: SendItemsService, useValue: sendItemsService }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: SendListFiltersService, useValue: sendListFiltersService }, ], }).compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; - fixture.detectChanges(); }); @@ -88,14 +114,21 @@ describe("SendV2Component", () => { expect(component).toBeTruthy(); }); - it("should sort sends by name on initialization", async () => { - const sortedSends = [ - { id: "1", name: "Send A" }, - { id: "2", name: "Send B" }, - ] as SendView[]; + it("should update the title based on the current filter", () => { + sendListFiltersServiceFilters$.next({ sendType: SendType.File }); + fixture.detectChanges(); + expect(component["title"]).toBe("fileSends"); + }); - await component.ngOnInit(); + it("should set listState to Empty when emptyList$ emits true", () => { + sendItemsServiceEmptyList$.next(true); + fixture.detectChanges(); + expect(component["listState"]).toBe(SendState.Empty); + }); - expect(component.sends).toEqual(sortedSends); + it("should set listState to NoResults when noFilteredResults$ emits true", () => { + sendItemsServiceNoFilteredResults$.next(true); + fixture.detectChanges(); + expect(component["listState"]).toBe(SendState.NoResults); }); }); diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 6ee5f832bea..5c1ec89fde9 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -1,18 +1,20 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { RouterLink } from "@angular/router"; -import { mergeMap, Subject, takeUntil } from "rxjs"; +import { combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, NoItemsModule } from "@bitwarden/components"; +import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; import { NoSendsIcon, NewSendDropdownComponent, SendListItemsContainerComponent, + SendItemsService, + SendSearchComponent, SendListFiltersComponent, + SendListFiltersService, } from "@bitwarden/send-ui"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; @@ -20,6 +22,11 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +export enum SendState { + Empty, + NoResults, +} + @Component({ templateUrl: "send-v2.component.html", standalone: true, @@ -36,29 +43,56 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co NewSendDropdownComponent, SendListItemsContainerComponent, SendListFiltersComponent, + SendSearchComponent, ], }) export class SendV2Component implements OnInit, OnDestroy { sendType = SendType; - private destroy$ = new Subject(); + sendState = SendState; - sends: SendView[] = []; + protected listState: SendState | null = null; + + protected sends$ = this.sendItemsService.filteredAndSortedSends$; + + protected title: string = "allSends"; protected noItemIcon = NoSendsIcon; - constructor(protected sendService: SendService) {} + protected noResultsIcon = Icons.NoResults; - async ngOnInit() { - this.sendService.sendViews$ - .pipe( - mergeMap(async (sends) => { - this.sends = sends.sort((a, b) => a.name.localeCompare(b.name)); - }), - takeUntil(this.destroy$), - ) - .subscribe(); + constructor( + protected sendItemsService: SendItemsService, + protected sendListFiltersService: SendListFiltersService, + ) { + combineLatest([ + this.sendItemsService.emptyList$, + this.sendItemsService.noFilteredResults$, + this.sendListFiltersService.filters$, + ]) + .pipe(takeUntilDestroyed()) + .subscribe(([emptyList, noFilteredResults, currentFilter]) => { + if (currentFilter?.sendType !== null) { + this.title = `${this.sendType[currentFilter.sendType].toLowerCase()}Sends`; + } else { + this.title = "allSends"; + } + + if (emptyList) { + this.listState = SendState.Empty; + return; + } + + if (noFilteredResults) { + this.listState = SendState.NoResults; + return; + } + + this.listState = null; + }); } + ngOnInit(): void {} + ngOnDestroy(): void {} } diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts index df4f184f7ff..c618c3dd148 100644 --- a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts @@ -30,6 +30,22 @@ import { closeFido2Popout, openFido2Popout } from "../popup/utils/vault-popout-w const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage"; +export const BrowserFido2MessageTypes = { + ConnectResponse: "ConnectResponse", + NewSessionCreatedRequest: "NewSessionCreatedRequest", + PickCredentialRequest: "PickCredentialRequest", + PickCredentialResponse: "PickCredentialResponse", + ConfirmNewCredentialRequest: "ConfirmNewCredentialRequest", + ConfirmNewCredentialResponse: "ConfirmNewCredentialResponse", + InformExcludedCredentialRequest: "InformExcludedCredentialRequest", + InformCredentialNotFoundRequest: "InformCredentialNotFoundRequest", + AbortRequest: "AbortRequest", + AbortResponse: "AbortResponse", +} as const; + +export type BrowserFido2MessageTypeValue = + (typeof BrowserFido2MessageTypes)[keyof typeof BrowserFido2MessageTypes]; + export class SessionClosedError extends Error { constructor() { super("Fido2UserInterfaceSession was closed"); @@ -39,30 +55,30 @@ export class SessionClosedError extends Error { export type BrowserFido2Message = { sessionId: string } & ( | /** * This message is used by popouts to announce that they are ready - * to recieve messages. + * to receive messages. **/ { - type: "ConnectResponse"; + type: typeof BrowserFido2MessageTypes.ConnectResponse; } /** * This message is used to announce the creation of a new session. * It is used by popouts to know when to close. **/ | { - type: "NewSessionCreatedRequest"; + type: typeof BrowserFido2MessageTypes.NewSessionCreatedRequest; } | { - type: "PickCredentialRequest"; + type: typeof BrowserFido2MessageTypes.PickCredentialRequest; cipherIds: string[]; userVerification: boolean; fallbackSupported: boolean; } | { - type: "PickCredentialResponse"; + type: typeof BrowserFido2MessageTypes.PickCredentialResponse; cipherId?: string; userVerified: boolean; } | { - type: "ConfirmNewCredentialRequest"; + type: typeof BrowserFido2MessageTypes.ConfirmNewCredentialRequest; credentialName: string; userName: string; userHandle: string; @@ -71,24 +87,24 @@ export type BrowserFido2Message = { sessionId: string } & ( rpId: string; } | { - type: "ConfirmNewCredentialResponse"; + type: typeof BrowserFido2MessageTypes.ConfirmNewCredentialResponse; cipherId: string; userVerified: boolean; } | { - type: "InformExcludedCredentialRequest"; + type: typeof BrowserFido2MessageTypes.InformExcludedCredentialRequest; existingCipherIds: string[]; fallbackSupported: boolean; } | { - type: "InformCredentialNotFoundRequest"; + type: typeof BrowserFido2MessageTypes.InformCredentialNotFoundRequest; fallbackSupported: boolean; } | { - type: "AbortRequest"; + type: typeof BrowserFido2MessageTypes.AbortRequest; } | { - type: "AbortResponse"; + type: typeof BrowserFido2MessageTypes.AbortResponse; fallbackRequested: boolean; } ); @@ -138,7 +154,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi static abortPopout(sessionId: string, fallbackRequested = false) { this.sendMessage({ sessionId: sessionId, - type: "AbortResponse", + type: BrowserFido2MessageTypes.AbortResponse, fallbackRequested: fallbackRequested, }); } @@ -146,7 +162,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi static confirmNewCredentialResponse(sessionId: string, cipherId: string, userVerified: boolean) { this.sendMessage({ sessionId: sessionId, - type: "ConfirmNewCredentialResponse", + type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse, cipherId, userVerified, }); @@ -169,7 +185,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) { this.messages$ .pipe( - filter((msg) => msg.type === "ConnectResponse"), + filter((msg) => msg.type === BrowserFido2MessageTypes.ConnectResponse), take(1), takeUntil(this.destroy$), ) @@ -185,7 +201,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi // eslint-disable-next-line @typescript-eslint/no-floating-promises this.close(); BrowserFido2UserInterfaceSession.sendMessage({ - type: "AbortRequest", + type: BrowserFido2MessageTypes.AbortRequest, sessionId: this.sessionId, }); }); @@ -193,12 +209,12 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi // Handle session aborted by user this.messages$ .pipe( - filter((msg) => msg.type === "AbortResponse"), + filter((msg) => msg.type === BrowserFido2MessageTypes.AbortResponse), take(1), takeUntil(this.destroy$), ) .subscribe((msg) => { - if (msg.type === "AbortResponse") { + if (msg.type === BrowserFido2MessageTypes.AbortResponse) { // 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.close(); @@ -217,7 +233,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi ); BrowserFido2UserInterfaceSession.sendMessage({ - type: "NewSessionCreatedRequest", + type: BrowserFido2MessageTypes.NewSessionCreatedRequest, sessionId, }); } @@ -227,7 +243,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi userVerification, }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { const data: BrowserFido2Message = { - type: "PickCredentialRequest", + type: BrowserFido2MessageTypes.PickCredentialRequest, cipherIds, sessionId: this.sessionId, userVerification, @@ -235,7 +251,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi }; await this.send(data); - const response = await this.receive("PickCredentialResponse"); + const response = await this.receive(BrowserFido2MessageTypes.PickCredentialResponse); return { cipherId: response.cipherId, userVerified: response.userVerified }; } @@ -248,7 +264,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi rpId, }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { const data: BrowserFido2Message = { - type: "ConfirmNewCredentialRequest", + type: BrowserFido2MessageTypes.ConfirmNewCredentialRequest, sessionId: this.sessionId, credentialName, userName, @@ -259,21 +275,21 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi }; await this.send(data); - const response = await this.receive("ConfirmNewCredentialResponse"); + const response = await this.receive(BrowserFido2MessageTypes.ConfirmNewCredentialResponse); return { cipherId: response.cipherId, userVerified: response.userVerified }; } async informExcludedCredential(existingCipherIds: string[]): Promise { const data: BrowserFido2Message = { - type: "InformExcludedCredentialRequest", + type: BrowserFido2MessageTypes.InformExcludedCredentialRequest, sessionId: this.sessionId, existingCipherIds, fallbackSupported: this.fallbackSupported, }; await this.send(data); - await this.receive("AbortResponse"); + await this.receive(BrowserFido2MessageTypes.AbortResponse); } async ensureUnlockedVault(): Promise { @@ -284,13 +300,13 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi async informCredentialNotFound(): Promise { const data: BrowserFido2Message = { - type: "InformCredentialNotFoundRequest", + type: BrowserFido2MessageTypes.InformCredentialNotFoundRequest, sessionId: this.sessionId, fallbackSupported: this.fallbackSupported, }; await this.send(data); - await this.receive("AbortResponse"); + await this.receive(BrowserFido2MessageTypes.AbortResponse); } async close() { diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 863b5e8dc39..a46f5a6955b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -4,7 +4,9 @@ [pageTitle]="headerText" [backAction]="handleBackButton.bind(this)" showBackButton - > + > + + >; PopupFooterComponent, CipherFormModule, AsyncActionsModule, + PopOutComponent, ], }) export class AddEditV2Component implements OnInit { diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 00a9d3f9489..6840924fb0a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -1,5 +1,7 @@ - + + + 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 bacb90f0439..add2efed96a 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 @@ -24,6 +24,7 @@ import { } from "@bitwarden/components"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; +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"; @@ -45,6 +46,7 @@ import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup IconButtonModule, CipherViewComponent, AsyncActionsModule, + PopOutComponent, ], }) export class ViewV2Component { diff --git a/apps/cli/package.json b/apps/cli/package.json index 8b541551eed..5f417579081 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.34", + "tldts": "6.1.38", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 563b205fa74..f308bdc2deb 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -10,7 +10,7 @@ import { ListResponse } from "./models/response/list.response"; import { MessageResponse } from "./models/response/message.response"; import { StringResponse } from "./models/response/string.response"; import { TemplateResponse } from "./models/response/template.response"; -import { ServiceContainer } from "./service-container"; +import { ServiceContainer } from "./service-container/service-container"; import { CliUtils } from "./utils"; const writeLn = CliUtils.writeLn; diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index e4c46dd9ee9..5e9d3dfbc94 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -3,7 +3,7 @@ import { program } from "commander"; import { OssServeConfigurator } from "./oss-serve-configurator"; import { registerOssPrograms } from "./register-oss-programs"; import { ServeProgram } from "./serve.program"; -import { ServiceContainer } from "./service-container"; +import { ServiceContainer } from "./service-container/service-container"; async function main() { const serviceContainer = new ServiceContainer(); diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index 05603a3c24a..801f505f1ae 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -7,7 +7,7 @@ import * as koaJson from "koa-json"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OssServeConfigurator } from "../oss-serve-configurator"; -import { ServiceContainer } from "../service-container"; +import { ServiceContainer } from "../service-container/service-container"; export class ServeCommand { constructor( diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 13f50da78b7..8a38f8f1280 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -13,7 +13,7 @@ import { RestoreCommand } from "./commands/restore.command"; import { StatusCommand } from "./commands/status.command"; import { Response } from "./models/response"; import { FileResponse } from "./models/response/file.response"; -import { ServiceContainer } from "./service-container"; +import { ServiceContainer } from "./service-container/service-container"; import { GenerateCommand } from "./tools/generate.command"; import { SendEditCommand, diff --git a/apps/cli/src/platform/services/cli-platform-utils.service.ts b/apps/cli/src/platform/services/cli-platform-utils.service.ts index 0950a7dfec0..2a39510fda8 100644 --- a/apps/cli/src/platform/services/cli-platform-utils.service.ts +++ b/apps/cli/src/platform/services/cli-platform-utils.service.ts @@ -139,6 +139,18 @@ export class CliPlatformUtilsService implements PlatformUtilsService { return Promise.resolve(false); } + biometricsNeedsSetup(): Promise { + return Promise.resolve(false); + } + + biometricsSupportsAutoSetup(): Promise { + return Promise.resolve(false); + } + + biometricsSetup(): Promise { + return Promise.resolve(); + } + supportsSecureStorage(): boolean { return false; } diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index d8aa54118d7..1fc1f0119d2 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -1,5 +1,5 @@ import { Program } from "./program"; -import { ServiceContainer } from "./service-container"; +import { ServiceContainer } from "./service-container/service-container"; import { SendProgram } from "./tools/send/send.program"; import { VaultProgram } from "./vault.program"; diff --git a/apps/cli/src/serve.program.ts b/apps/cli/src/serve.program.ts index bbf66661e5b..ef18a3e35ce 100644 --- a/apps/cli/src/serve.program.ts +++ b/apps/cli/src/serve.program.ts @@ -3,7 +3,7 @@ import { program } from "commander"; import { BaseProgram } from "./base-program"; import { ServeCommand } from "./commands/serve.command"; import { OssServeConfigurator } from "./oss-serve-configurator"; -import { ServiceContainer } from "./service-container"; +import { ServiceContainer } from "./service-container/service-container"; import { CliUtils } from "./utils"; const writeLn = CliUtils.writeLn; diff --git a/apps/cli/src/service-container.spec.ts b/apps/cli/src/service-container/service-container.spec.ts similarity index 100% rename from apps/cli/src/service-container.spec.ts rename to apps/cli/src/service-container/service-container.spec.ts diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container/service-container.ts similarity index 96% rename from apps/cli/src/service-container.ts rename to apps/cli/src/service-container/service-container.ts index 8ee99fa2039..9cb70952593 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -44,7 +44,10 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; -import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { + AutofillSettingsService, + AutofillSettingsServiceAbstraction, +} from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DefaultDomainSettingsService, DomainSettingsService, @@ -147,18 +150,18 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; -import { CliPlatformUtilsService } from "./platform/services/cli-platform-utils.service"; -import { ConsoleLogService } from "./platform/services/console-log.service"; -import { I18nService } from "./platform/services/i18n.service"; -import { LowdbStorageService } from "./platform/services/lowdb-storage.service"; -import { NodeApiService } from "./platform/services/node-api.service"; -import { NodeEnvSecureStorageService } from "./platform/services/node-env-secure-storage.service"; +import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service"; +import { ConsoleLogService } from "../platform/services/console-log.service"; +import { I18nService } from "../platform/services/i18n.service"; +import { LowdbStorageService } from "../platform/services/lowdb-storage.service"; +import { NodeApiService } from "../platform/services/node-api.service"; +import { NodeEnvSecureStorageService } from "../platform/services/node-env-secure-storage.service"; // Polyfills global.DOMParser = new jsdom.JSDOM().window.DOMParser; // eslint-disable-next-line -const packageJson = require("../package.json"); +const packageJson = require("../../package.json"); /** * Instantiates services and makes them available for dependency injection. @@ -254,13 +257,13 @@ export class ServiceContainer { } else if (process.env.BITWARDENCLI_APPDATA_DIR) { p = path.resolve(process.env.BITWARDENCLI_APPDATA_DIR); } else if (process.platform === "darwin") { - p = path.join(process.env.HOME, "Library/Application Support/Bitwarden CLI"); + p = path.join(process.env.HOME ?? "", "Library/Application Support/Bitwarden CLI"); } else if (process.platform === "win32") { - p = path.join(process.env.APPDATA, "Bitwarden CLI"); + p = path.join(process.env.APPDATA ?? "", "Bitwarden CLI"); } else if (process.env.XDG_CONFIG_HOME) { p = path.join(process.env.XDG_CONFIG_HOME, "Bitwarden CLI"); } else { - p = path.join(process.env.HOME, ".config/Bitwarden CLI"); + p = path.join(process.env.HOME ?? "", ".config/Bitwarden CLI"); } const logoutCallback = async () => await this.logout(); @@ -452,8 +455,6 @@ export class ServiceContainer { customUserAgent, ); - this.organizationApiService = new OrganizationApiService(this.apiService, this.syncService); - this.containerService = new ContainerService(this.cryptoService, this.encryptService); this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); @@ -524,6 +525,40 @@ export class ServiceContainer { this.stateProvider, ); + this.authRequestService = new AuthRequestService( + this.appIdService, + this.accountService, + this.masterPasswordService, + this.cryptoService, + this.apiService, + this.stateProvider, + ); + + this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( + this.stateProvider, + ); + + this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService); + + this.authService = new AuthService( + this.accountService, + this.messagingService, + this.cryptoService, + this.apiService, + this.stateService, + this.tokenService, + ); + + this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + + this.configService = new DefaultConfigService( + this.configApiService, + this.environmentService, + this.logService, + this.stateProvider, + this.authService, + ); + this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); this.deviceTrustService = new DeviceTrustService( this.keyGenerationService, @@ -541,20 +576,6 @@ export class ServiceContainer { this.configService, ); - this.authRequestService = new AuthRequestService( - this.appIdService, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.stateProvider, - ); - - this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( - this.stateProvider, - ); - - this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService); this.loginStrategyService = new LoginStrategyService( this.accountService, this.masterPasswordService, @@ -583,23 +604,10 @@ export class ServiceContainer { this.taskSchedulerService, ); - this.authService = new AuthService( - this.accountService, - this.messagingService, - this.cryptoService, - this.apiService, - this.stateService, - this.tokenService, - ); - - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); - - this.configService = new DefaultConfigService( - this.configApiService, - this.environmentService, - this.logService, + // FIXME: CLI does not support autofill + this.autofillSettingsService = new AutofillSettingsService( this.stateProvider, - this.authService, + this.policyService, ); this.cipherService = new CipherService( @@ -661,7 +669,7 @@ export class ServiceContainer { this.taskSchedulerService, this.logService, lockedCallback, - null, + undefined, ); this.avatarService = new AvatarService(this.apiService, this.stateProvider); @@ -691,6 +699,7 @@ export class ServiceContainer { this.billingAccountProfileStateService, this.tokenService, this.authService, + this.stateProvider, ); this.totpService = new TotpService(this.cryptoFunctionService, this.logService); @@ -752,6 +761,8 @@ export class ServiceContainer { this.accountService, ); + this.organizationApiService = new OrganizationApiService(this.apiService, this.syncService); + this.providerApiService = new ProviderApiService(this.apiService); } @@ -762,7 +773,6 @@ export class ServiceContainer { const userId = (await this.stateService.getUserId()) as UserId; await Promise.all([ this.eventUploadService.uploadEvents(userId as UserId), - this.syncService.setLastSync(new Date(0)), this.cryptoService.clearKeys(), this.cipherService.clear(userId), this.folderService.clear(userId), @@ -774,7 +784,7 @@ export class ServiceContainer { await this.stateService.clean(); await this.accountService.clean(userId); await this.accountService.switchAccount(null); - process.env.BW_SESSION = null; + process.env.BW_SESSION = undefined; } async init() { @@ -790,7 +800,7 @@ export class ServiceContainer { this.twoFactorService.init(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - if (activeAccount) { + if (activeAccount?.id) { await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id); } diff --git a/apps/cli/src/service-container/tsconfig.json b/apps/cli/src/service-container/tsconfig.json new file mode 100644 index 00000000000..ee24d230ae2 --- /dev/null +++ b/apps/cli/src/service-container/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "strictNullChecks": true, + "strictPropertyInitialization": true + } +} diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index d3bc04b1dc3..e98aba8622d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -282,12 +282,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bytes" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" - [[package]] name = "cbc" version = "0.1.2" @@ -496,6 +490,7 @@ dependencies = [ "core-foundation", "gio", "keytar", + "libc", "libsecret", "rand", "retry", @@ -509,6 +504,7 @@ dependencies = [ "widestring", "windows", "zbus", + "zbus_polkit", ] [[package]] @@ -2282,6 +2278,19 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus_polkit" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00a29bfa927b29f91b7feb4e1990f2dd1b4604072f493dc2f074cf59e4e0ba90" +dependencies = [ + "enumflags2", + "serde", + "serde_repr", + "static_assertions", + "zbus", +] + [[package]] name = "zvariant" version = "4.1.2" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index b29ea32b60f..bd95f1132a6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -17,6 +17,7 @@ arboard = { version = "=3.4.0", default-features = false, features = [ ] } base64 = "=0.22.1" cbc = { version = "=0.1.2", features = ["alloc"] } +libc = "0.2.155" rand = "=0.8.5" retry = "=2.0.0" scopeguard = "=1.2.0" @@ -51,3 +52,4 @@ security-framework-sys = "=2.11.0" gio = "=0.19.5" libsecret = "=0.5.0" zbus = "4.3.1" +zbus_polkit = "4.0.0" diff --git a/apps/desktop/desktop_native/core/src/biometric/macos.rs b/apps/desktop/desktop_native/core/src/biometric/macos.rs index 858615d2e7e..01ee4519ce6 100644 --- a/apps/desktop/desktop_native/core/src/biometric/macos.rs +++ b/apps/desktop/desktop_native/core/src/biometric/macos.rs @@ -6,11 +6,11 @@ use crate::biometric::{KeyMaterial, OsDerivedKey}; pub struct Biometric {} impl super::BiometricTrait for Biometric { - fn prompt(_hwnd: Vec, _message: String) -> Result { + async fn prompt(_hwnd: Vec, _message: String) -> Result { bail!("platform not supported"); } - fn available() -> Result { + async fn available() -> Result { bail!("platform not supported"); } diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index f61c4f04443..c41ad9dda53 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -1,4 +1,5 @@ -use anyhow::Result; +use aes::cipher::generic_array::GenericArray; +use anyhow::{anyhow, Result}; #[cfg_attr(target_os = "linux", path = "unix.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] @@ -6,6 +7,10 @@ use anyhow::Result; mod biometric; pub use biometric::Biometric; +use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; +use sha2::{Digest, Sha256}; + +use crate::crypto::{self, CipherString}; pub struct KeyMaterial { pub os_key_part_b64: String, @@ -18,8 +23,10 @@ pub struct OsDerivedKey { } pub trait BiometricTrait { - fn prompt(hwnd: Vec, message: String) -> Result; - fn available() -> Result; + #[allow(async_fn_in_trait)] + async fn prompt(hwnd: Vec, message: String) -> Result; + #[allow(async_fn_in_trait)] + async fn available() -> Result; fn derive_key_material(secret: Option<&str>) -> Result; fn set_biometric_secret( service: &str, @@ -34,3 +41,40 @@ pub trait BiometricTrait { key_material: Option, ) -> Result; } + + +fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result { + let iv = base64_engine + .decode(iv_b64)? + .try_into() + .map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?; + + let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?; + + Ok(encrypted.to_string()) +} + +fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result { + if let CipherString::AesCbc256_B64 { iv, data } = secret { + let decrypted = crypto::decrypt_aes256(&iv, &data, key_material.derive_key()?)?; + + Ok(String::from_utf8(decrypted)?) + } else { + Err(anyhow!("Invalid cipher string")) + } +} + +impl KeyMaterial { + fn digest_material(&self) -> String { + match self.client_key_part_b64.as_deref() { + Some(client_key_part_b64) => { + format!("{}|{}", self.os_key_part_b64, client_key_part_b64) + } + None => self.os_key_part_b64.clone(), + } + } + + pub fn derive_key(&self) -> Result> { + Ok(Sha256::digest(self.digest_material())) + } +} \ No newline at end of file diff --git a/apps/desktop/desktop_native/core/src/biometric/unix.rs b/apps/desktop/desktop_native/core/src/biometric/unix.rs index f9fe1ba57ca..742b736e812 100644 --- a/apps/desktop/desktop_native/core/src/biometric/unix.rs +++ b/apps/desktop/desktop_native/core/src/biometric/unix.rs @@ -1,38 +1,109 @@ -use anyhow::{bail, Result}; +use std::str::FromStr; -use crate::biometric::{KeyMaterial, OsDerivedKey}; +use anyhow::Result; +use base64::Engine; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine}; +use zbus::Connection; +use zbus_polkit::policykit1::*; + +use super::{decrypt, encrypt}; +use anyhow::anyhow; +use crate::crypto::CipherString; /// The Unix implementation of the biometric trait. pub struct Biometric {} impl super::BiometricTrait for Biometric { - fn prompt(_hwnd: Vec, _message: String) -> Result { - bail!("platform not supported"); + async fn prompt(_hwnd: Vec, _message: String) -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let subject = Subject::new_for_owner(std::process::id(), None, None)?; + let details = std::collections::HashMap::new(); + let result = proxy.check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ).await; + + match result { + Ok(result) => { + return Ok(result.is_authorized); + } + Err(e) => { + println!("polkit biometric error: {:?}", e); + return Ok(false); + } + } } - fn available() -> Result { - bail!("platform not supported"); + async fn available() -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let res = proxy.enumerate_actions("en").await?; + for action in res { + if action.action_id == "com.bitwarden.Bitwarden.unlock" { + return Ok(true); + } + } + return Ok(false); } - fn derive_key_material(_iv_str: Option<&str>) -> Result { - bail!("platform not supported"); - } + fn derive_key_material(challenge_str: Option<&str>) -> Result { + let challenge: [u8; 16] = match challenge_str { + Some(challenge_str) => base64_engine + .decode(challenge_str)? + .try_into() + .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, + None => random_challenge(), + }; - fn get_biometric_secret( - _service: &str, - _account: &str, - _key_material: Option, - ) -> Result { - bail!("platform not supported"); + // there is no windows hello like interactive bio protected secret at the moment on linux + // so we use a a key derived from the iv. this key is not intended to add any security + // but only a place-holder + let key = Sha256::digest(challenge); + let key_b64 = base64_engine.encode(&key); + let iv_b64 = base64_engine.encode(&challenge); + Ok(OsDerivedKey { key_b64, iv_b64 }) } fn set_biometric_secret( - _service: &str, - _account: &str, - _secret: &str, - _key_material: Option, - _iv_b64: &str, + service: &str, + account: &str, + secret: &str, + key_material: Option, + iv_b64: &str, ) -> Result { - bail!("platform not supported"); + let key_material = key_material.ok_or(anyhow!( + "Key material is required for polkit protected keys" + ))?; + + let encrypted_secret = encrypt(secret, &key_material, iv_b64)?; + crate::password::set_password(service, account, &encrypted_secret)?; + Ok(encrypted_secret) + } + + fn get_biometric_secret( + service: &str, + account: &str, + key_material: Option, + ) -> Result { + let key_material = key_material.ok_or(anyhow!( + "Key material is required for polkit protected keys" + ))?; + + let encrypted_secret = crate::password::get_password(service, account)?; + let secret = CipherString::from_str(&encrypted_secret)?; + return Ok(decrypt(&secret, &key_material)?); } } + +fn random_challenge() -> [u8; 16] { + let mut challenge = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut challenge); + challenge +} \ No newline at end of file diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index 1f5929a3ada..c5db9e3277b 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -1,6 +1,5 @@ use std::str::FromStr; -use aes::cipher::generic_array::GenericArray; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; use rand::RngCore; @@ -30,14 +29,16 @@ use windows::{ use crate::{ biometric::{KeyMaterial, OsDerivedKey}, - crypto::{self, CipherString}, + crypto::CipherString, }; +use super::{decrypt, encrypt}; + /// The Windows OS implementation of the biometric trait. pub struct Biometric {} impl super::BiometricTrait for Biometric { - fn prompt(hwnd: Vec, message: String) -> Result { + async fn prompt(hwnd: Vec, message: String) -> Result { let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap()); let window = HWND(h); @@ -56,7 +57,7 @@ impl super::BiometricTrait for Biometric { } } - fn available() -> Result { + async fn available() -> Result { let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?; match ucv_available { @@ -159,26 +160,6 @@ impl super::BiometricTrait for Biometric { } } -fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result { - let iv = base64_engine - .decode(iv_b64)? - .try_into() - .map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?; - - let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?; - - Ok(encrypted.to_string()) -} - -fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result { - if let CipherString::AesCbc256_B64 { iv, data } = secret { - let decrypted = crypto::decrypt_aes256(&iv, &data, key_material.derive_key()?)?; - - Ok(String::from_utf8(decrypted)?) - } else { - Err(anyhow!("Invalid cipher string")) - } -} fn random_challenge() -> [u8; 16] { let mut challenge = [0u8; 16]; @@ -237,26 +218,11 @@ fn set_focus(window: HWND) { } } -impl KeyMaterial { - fn digest_material(&self) -> String { - match self.client_key_part_b64.as_deref() { - Some(client_key_part_b64) => { - format!("{}|{}", self.os_key_part_b64, client_key_part_b64) - } - None => self.os_key_part_b64.clone(), - } - } - - pub fn derive_key(&self) -> Result> { - Ok(Sha256::digest(self.digest_material())) - } -} - #[cfg(test)] mod tests { use super::*; - use crate::biometric::BiometricTrait; + use crate::biometric::{encrypt, BiometricTrait}; #[test] #[cfg(feature = "manual_test")] diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index 1b6ee2c771f..d23a285b4ac 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -3,4 +3,5 @@ pub mod clipboard; pub mod crypto; pub mod error; pub mod password; +pub mod process_isolation; pub mod powermonitor; diff --git a/apps/desktop/desktop_native/core/src/password/macos.rs b/apps/desktop/desktop_native/core/src/password/macos.rs index 7f0c3d9f618..408706423e2 100644 --- a/apps/desktop/desktop_native/core/src/password/macos.rs +++ b/apps/desktop/desktop_native/core/src/password/macos.rs @@ -22,6 +22,10 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> { Ok(result) } +pub fn is_available() -> Result { + Ok(true) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/desktop_native/core/src/password/unix.rs b/apps/desktop/desktop_native/core/src/password/unix.rs index fa808613dfc..53053ee467e 100644 --- a/apps/desktop/desktop_native/core/src/password/unix.rs +++ b/apps/desktop/desktop_native/core/src/password/unix.rs @@ -40,6 +40,17 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> { Ok(result) } +pub fn is_available() -> Result { + let result = password_clear_sync(Some(&get_schema()), build_attributes("bitwardenSecretsAvailabilityTest", "test"), gio::Cancellable::NONE); + match result { + Ok(_) => Ok(true), + Err(_) => { + println!("secret-service unavailable: {:?}", result); + Ok(false) + } + } +} + fn get_schema() -> Schema { let mut attributes = std::collections::HashMap::new(); attributes.insert("service", libsecret::SchemaAttributeType::String); diff --git a/apps/desktop/desktop_native/core/src/password/windows.rs b/apps/desktop/desktop_native/core/src/password/windows.rs index 533604e4bac..d932aabae95 100644 --- a/apps/desktop/desktop_native/core/src/password/windows.rs +++ b/apps/desktop/desktop_native/core/src/password/windows.rs @@ -122,6 +122,10 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> { Ok(()) } +pub fn is_available() -> Result { + Ok(true) +} + fn target_name(service: &str, account: &str) -> String { format!("{}/{}", service, account) } diff --git a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs new file mode 100644 index 00000000000..ba8734cff7f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use libc::{c_int, self}; +#[cfg(target_env = "gnu")] +use libc::c_uint; + +// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes +// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 +#[cfg(target_env = "musl")] +const RLIMIT_CORE: c_int = 4; +#[cfg(target_env = "gnu")] +const RLIMIT_CORE: c_uint = 4; + +// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of this process +// or attach a debugger to it. +// https://github.com/torvalds/linux/blob/a38297e3fb012ddfa7ce0321a7e5a8daeb1872b6/include/uapi/linux/prctl.h#L14 +const PR_SET_DUMPABLE: c_int = 4; + +pub fn disable_coredumps() -> Result<()> { + let rlimit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 { + let e = std::io::Error::last_os_error(); + return Err(anyhow::anyhow!("failed to disable core dumping, memory might be persisted to disk on crashes {}", e)) + } + + Ok(()) +} + +pub fn is_core_dumping_disabled() -> Result { + let mut rlimit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 { + let e = std::io::Error::last_os_error(); + return Err(anyhow::anyhow!("failed to get core dump limit {}", e)) + } + + Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0) +} + +pub fn disable_memory_access() -> Result<()> { + if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 { + let e = std::io::Error::last_os_error(); + return Err(anyhow::anyhow!("failed to disable memory dumping, memory is dumpable by other processes {}", e)) + } + + Ok(()) +} diff --git a/apps/desktop/desktop_native/core/src/process_isolation/macos.rs b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs new file mode 100644 index 00000000000..04d8f7266c4 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs @@ -0,0 +1,13 @@ +use anyhow::{bail, Result}; + +pub fn disable_coredumps() -> Result<()> { + bail!("Not implemented on Mac") +} + +pub fn is_core_dumping_disabled() -> Result { + bail!("Not implemented on Mac") +} + +pub fn disable_memory_access() -> Result<()> { + bail!("Not implemented on Mac") +} diff --git a/apps/desktop/desktop_native/core/src/process_isolation/mod.rs b/apps/desktop/desktop_native/core/src/process_isolation/mod.rs new file mode 100644 index 00000000000..7c9aaf3bcf7 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/process_isolation/mod.rs @@ -0,0 +1,5 @@ +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod process_isolation; +pub use process_isolation::*; diff --git a/apps/desktop/desktop_native/core/src/process_isolation/windows.rs b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs new file mode 100644 index 00000000000..7c7864fbbd7 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs @@ -0,0 +1,13 @@ +use anyhow::{bail, Result}; + +pub fn disable_coredumps() -> Result<()> { + bail!("Not implemented on Windows") +} + +pub fn is_core_dumping_disabled() -> Result { + bail!("Not implemented on Windows") +} + +pub fn disable_memory_access() -> Result<()> { + bail!("Not implemented on Windows") +} diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index fdb48543e8d..dc3cc7ec0bb 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -12,6 +12,7 @@ export namespace passwords { export function setPassword(service: string, account: string, password: string): Promise /** Delete the stored password from the keychain. */ export function deletePassword(service: string, account: string): Promise + export function isAvailable(): Promise } export namespace biometrics { export function prompt(hwnd: Buffer, message: string): Promise @@ -41,6 +42,12 @@ export namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise } +export namespace processisolations { + export function disableCoredumps(): Promise + export function isCoreDumpingDisabled(): Promise + export function disableMemoryAccess(): Promise +} + export namespace powermonitors { export function onLock(callback: (err: Error | null, ) => any): Promise export function isLockMonitorAvailable(): Promise diff --git a/apps/desktop/desktop_native/napi/index.js b/apps/desktop/desktop_native/napi/index.js index 92e58a21705..680f1302b9a 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -206,9 +206,10 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { passwords, biometrics, clipboards, powermonitors } = nativeBinding +const { passwords, biometrics, clipboards, processisolations, powermonitors } = nativeBinding module.exports.passwords = passwords module.exports.biometrics = biometrics module.exports.clipboards = clipboards +module.exports.processisolations = processisolations module.exports.powermonitors = powermonitors diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index fdb3efcc095..dfdc316d259 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -33,6 +33,12 @@ pub mod passwords { desktop_core::password::delete_password(&service, &account) .map_err(|e| napi::Error::from_reason(e.to_string())) } + + // Checks if the os secure storage is available + #[napi] + pub async fn is_available() -> napi::Result { + desktop_core::password::is_available().map_err(|e| napi::Error::from_reason(e.to_string())) + } } #[napi] @@ -45,12 +51,12 @@ pub mod biometrics { hwnd: napi::bindgen_prelude::Buffer, message: String, ) -> napi::Result { - Biometric::prompt(hwnd.into(), message).map_err(|e| napi::Error::from_reason(e.to_string())) + Biometric::prompt(hwnd.into(), message).await.map_err(|e| napi::Error::from_reason(e.to_string())) } #[napi] pub async fn available() -> napi::Result { - Biometric::available().map_err(|e| napi::Error::from_reason(e.to_string())) + Biometric::available().await.map_err(|e| napi::Error::from_reason(e.to_string())) } #[napi] @@ -142,6 +148,25 @@ pub mod clipboards { } } +#[napi] +pub mod processisolations { + #[napi] + pub async fn disable_coredumps() -> napi::Result<()> { + desktop_core::process_isolation::disable_coredumps() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + #[napi] + pub async fn is_core_dumping_disabled() -> napi::Result { + desktop_core::process_isolation::is_core_dumping_disabled() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + #[napi] + pub async fn disable_memory_access() -> napi::Result<()> { + desktop_core::process_isolation::disable_memory_access() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} + #[napi] pub mod powermonitors { use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio}; diff --git a/apps/desktop/resources/memory-dump-wrapper.sh b/apps/desktop/resources/memory-dump-wrapper.sh index fa71cd73764..b62c050683a 100644 --- a/apps/desktop/resources/memory-dump-wrapper.sh +++ b/apps/desktop/resources/memory-dump-wrapper.sh @@ -3,6 +3,10 @@ # disable core dumps ulimit -c 0 -APP_PATH=$(dirname "$0") +# might be behind symlink +RAW_PATH=$(readlink -f "$0") +APP_PATH=$(dirname $RAW_PATH) + # pass through all args -$APP_PATH/bitwarden-app "$@" \ No newline at end of file +$APP_PATH/bitwarden-app "$@" + diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 9245c51d555..359d856525e 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -126,11 +126,14 @@ {{ biometricText | i18n }}
- {{ + {{ additionalBiometricSettingsText | i18n }} -
+