diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index a37a2e07678..99d0d9031cf 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -1,6 +1,6 @@ import { mock } from "jest-mock-extended"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils"; @@ -34,10 +34,10 @@ describe("ContentMessageHandler", () => { const mockPostMessage = jest.fn(); window.postMessage = mockPostMessage; - postWindowMessage({ command: VaultOnboardingMessages.checkBwInstalled }); + postWindowMessage({ command: VaultMessages.checkBwInstalled }); expect(mockPostMessage).toHaveBeenCalledWith({ - command: VaultOnboardingMessages.HasBwInstalled, + command: VaultMessages.HasBwInstalled, }); }); }); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index ef542896492..5f98cf348a3 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -1,4 +1,4 @@ -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { ContentMessageWindowData, @@ -26,16 +26,17 @@ const windowMessageHandlers: ContentMessageWindowEventHandlers = { handleAuthResultMessage(data, referrer), webAuthnResult: ({ data, referrer }: { data: any; referrer: string }) => handleWebAuthnResultMessage(data, referrer), - checkIfBWExtensionInstalled: () => handleExtensionInstallCheck(), + [VaultMessages.checkBwInstalled]: () => handleExtensionInstallCheck(), duoResult: ({ data, referrer }: { data: any; referrer: string }) => handleDuoResultMessage(data, referrer), + [VaultMessages.OpenPopup]: () => handleOpenPopupMessage(), }; /** * Handles the post to the web vault showing the extension has been installed */ function handleExtensionInstallCheck() { - window.postMessage({ command: VaultOnboardingMessages.HasBwInstalled }); + window.postMessage({ command: VaultMessages.HasBwInstalled }); } /** @@ -71,6 +72,10 @@ function handleWebAuthnResultMessage(data: ContentMessageWindowData, referrer: s sendExtensionRuntimeMessage({ command, data: data.data, remember, referrer }); } +function handleOpenPopupMessage() { + sendExtensionRuntimeMessage({ command: VaultMessages.OpenPopup }); +} + /** * Handles the window message event. * diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1c6d018a82c..8faaec4f023 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1592,13 +1592,16 @@ export default class MainBackground { } async openPopup() { - // Chrome APIs cannot open popup + const browserAction = BrowserApi.getBrowserAction(); - // TODO: Do we need to open this popup? - if (!this.isSafari) { + if ("openPopup" in browserAction && typeof browserAction.openPopup === "function") { + await browserAction.openPopup(); return; } - await SafariApp.sendMessageToApp("showPopover", null, true); + + if (this.isSafari) { + await SafariApp.sendMessageToApp("showPopover", null, true); + } } async reseedStorage() { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2a756293070..a5fea0651fc 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -289,7 +289,7 @@ export default class RuntimeBackground { } break; case "openPopup": - await this.main.openPopup(); + await this.openPopup(); break; case "bgUpdateContextMenu": case "editedCipher": @@ -405,13 +405,40 @@ export default class RuntimeBackground { }, 100); } + /** Returns the browser tabs that have the web vault open */ + private async getBwTabs() { + const env = await firstValueFrom(this.environmentService.environment$); + const vaultUrl = env.getWebVaultUrl(); + const urlObj = new URL(vaultUrl); + + return await BrowserApi.tabsQuery({ url: `${urlObj.href}*` }); + } + + private async openPopup() { + await this.main.openPopup(); + + const announcePopupOpen = async () => { + const isOpen = await this.platformUtilsService.isViewOpen(); + const tabs = await this.getBwTabs(); + + if (isOpen && tabs.length > 0) { + // Send message to all vault tabs that the extension has opened + for (const tab of tabs) { + await BrowserApi.executeScriptInTab(tab.id, { + file: "content/send-popup-open-message.js", + runAt: "document_end", + }); + } + } + }; + + // Give the popup a buffer to open + setTimeout(announcePopupOpen, 100); + } + async sendBwInstalledMessageToVault() { try { - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - const urlObj = new URL(vaultUrl); - - const tabs = await BrowserApi.tabsQuery({ url: `${urlObj.href}*` }); + const tabs = await this.getBwTabs(); if (!tabs?.length) { return; diff --git a/apps/browser/src/vault/content/send-on-installed-message.ts b/apps/browser/src/vault/content/send-on-installed-message.ts index 9df15eb0d51..8da9e250249 100644 --- a/apps/browser/src/vault/content/send-on-installed-message.ts +++ b/apps/browser/src/vault/content/send-on-installed-message.ts @@ -1,5 +1,5 @@ -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; (function (globalContext) { - globalContext.postMessage({ command: VaultOnboardingMessages.HasBwInstalled }); + globalContext.postMessage({ command: VaultMessages.HasBwInstalled }); })(window); diff --git a/apps/browser/src/vault/content/send-popup-open-message.ts b/apps/browser/src/vault/content/send-popup-open-message.ts new file mode 100644 index 00000000000..6889d24f7f6 --- /dev/null +++ b/apps/browser/src/vault/content/send-popup-open-message.ts @@ -0,0 +1,6 @@ +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +(function (globalContext) { + // Send a message to the window that the popup opened + globalContext.postMessage({ command: VaultMessages.PopupOpened }); +})(window); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index b6e8a147a50..a5fe6ed94fd 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -207,6 +207,7 @@ const mainConfig = { "./src/autofill/deprecated/overlay/pages/list/bootstrap-autofill-overlay-list.deprecated.ts", "encrypt-worker": "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", + "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", }, optimization: { minimize: ENV !== "development", diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 5f2b839ae97..554f1f62e24 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -92,6 +92,8 @@ import { CredentialGeneratorComponent } from "./tools/credential-generator/crede import { ReportsModule } from "./tools/reports"; import { AccessComponent, SendAccessExplainerComponent } from "./tools/send/send-access"; import { SendComponent } from "./tools/send/send.component"; +import { BrowserExtensionPromptInstallComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt-install.component"; +import { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component"; import { VaultModule } from "./vault/individual-vault/vault.module"; const routes: Routes = [ @@ -695,6 +697,23 @@ const routes: Routes = [ maxWidth: "3xl", } satisfies AnonLayoutWrapperData, }, + { + path: "browser-extension-prompt", + data: { + pageIcon: VaultIcons.BrowserExtensionIcon, + } satisfies AnonLayoutWrapperData, + children: [ + { + path: "", + component: BrowserExtensionPromptComponent, + }, + { + path: "", + component: BrowserExtensionPromptInstallComponent, + outlet: "secondary", + }, + ], + }, ], }, { diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html new file mode 100644 index 00000000000..709f4e8993e --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.html @@ -0,0 +1,4 @@ +
+

{{ "doNotHaveExtension" | i18n }}

+ {{ "installExtension" | i18n }} +
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts new file mode 100644 index 00000000000..e3729130a01 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { DeviceType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +import { BrowserExtensionPromptInstallComponent } from "./browser-extension-prompt-install.component"; + +describe("BrowserExtensionInstallComponent", () => { + let fixture: ComponentFixture; + let component: BrowserExtensionPromptInstallComponent; + const pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + const getDevice = jest.fn(); + + beforeEach(async () => { + getDevice.mockClear(); + await TestBed.configureTestingModule({ + providers: [ + { + provide: BrowserExtensionPromptService, + useValue: { pageState$ }, + }, + { + provide: I18nService, + useValue: { t: (key: string) => key }, + }, + { + provide: PlatformUtilsService, + useValue: { getDevice }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BrowserExtensionPromptInstallComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("only shows during error state", () => { + expect(fixture.nativeElement.textContent).toBe(""); + + pageState$.next(BrowserPromptState.Success); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe(""); + + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toBe(""); + + pageState$.next(BrowserPromptState.ManualOpen); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toBe(""); + }); + + describe("error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + }); + + it("shows error text", () => { + const errorText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(errorText.textContent).toBe("doNotHaveExtension"); + }); + + it("links to bitwarden installation page by default", () => { + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://bitwarden.com/download/#downloads-web-browser", + ); + }); + + it("links to bitwarden installation page for Chrome", () => { + getDevice.mockReturnValue(DeviceType.ChromeBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://chrome.google.com/webstore/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb", + ); + }); + + it("links to bitwarden installation page for Firefox", () => { + getDevice.mockReturnValue(DeviceType.FirefoxBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/", + ); + }); + + it("links to bitwarden installation page for Safari", () => { + getDevice.mockReturnValue(DeviceType.SafariBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12", + ); + }); + + it("links to bitwarden installation page for Opera", () => { + getDevice.mockReturnValue(DeviceType.OperaBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/", + ); + }); + + it("links to bitwarden installation page for Edge", () => { + getDevice.mockReturnValue(DeviceType.EdgeBrowser); + component.ngOnInit(); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css("a")).nativeElement; + + expect(link.getAttribute("href")).toBe( + "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh", + ); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts new file mode 100644 index 00000000000..73f4307d9cc --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt-install.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { map } from "rxjs"; + +import { DeviceType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LinkModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +/** Device specific Urls for the extension */ +const WebStoreUrls: Partial> = { + [DeviceType.ChromeBrowser]: + "https://chrome.google.com/webstore/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb", + [DeviceType.FirefoxBrowser]: + "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/", + [DeviceType.SafariBrowser]: "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12", + [DeviceType.OperaBrowser]: + "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/", + [DeviceType.EdgeBrowser]: + "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh", +}; + +@Component({ + selector: "vault-browser-extension-prompt-install", + templateUrl: "./browser-extension-prompt-install.component.html", + standalone: true, + imports: [CommonModule, I18nPipe, LinkModule], +}) +export class BrowserExtensionPromptInstallComponent implements OnInit { + /** The install link should only show for the error states */ + protected shouldShow$ = this.browserExtensionPromptService.pageState$.pipe( + map((state) => state === BrowserPromptState.Error || state === BrowserPromptState.ManualOpen), + ); + + /** All available page states */ + protected BrowserPromptState = BrowserPromptState; + + /** + * Installation link for the extension + */ + protected webStoreUrl: string = "https://bitwarden.com/download/#downloads-web-browser"; + + constructor( + private browserExtensionPromptService: BrowserExtensionPromptService, + private platformService: PlatformUtilsService, + ) {} + + ngOnInit(): void { + this.setBrowserStoreLink(); + } + + /** If available, set web store specific URL for the extension */ + private setBrowserStoreLink(): void { + const deviceType = this.platformService.getDevice(); + const platformSpecificUrl = WebStoreUrls[deviceType]; + + if (platformSpecificUrl) { + this.webStoreUrl = platformSpecificUrl; + } + } +} diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html new file mode 100644 index 00000000000..1c643fcc3e4 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html @@ -0,0 +1,44 @@ +
+ + +

{{ "openingExtension" | i18n }}

+
+ + +

{{ "openingExtensionError" | i18n }}

+ +
+ + + +

+ {{ "openedExtensionViewAtRiskPasswords" | i18n }} +

+
+ + +

+ {{ "openExtensionManuallyPart1" | i18n }} + + {{ "openExtensionManuallyPart2" | i18n }} +

+
+ + +

+ {{ "reopenLinkOnDesktop" | i18n }} +

+
+
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts new file mode 100644 index 00000000000..40dbc0d442e --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts @@ -0,0 +1,104 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +import { BrowserExtensionPromptComponent } from "./browser-extension-prompt.component"; + +describe("BrowserExtensionPromptComponent", () => { + let fixture: ComponentFixture; + + const start = jest.fn(); + const pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + beforeEach(async () => { + start.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { + provide: BrowserExtensionPromptService, + useValue: { start, pageState$ }, + }, + { + provide: I18nService, + useValue: { t: (key: string) => key }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BrowserExtensionPromptComponent); + fixture.detectChanges(); + }); + + it("calls start on initialization", () => { + expect(start).toHaveBeenCalledTimes(1); + }); + + describe("loading state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Loading); + fixture.detectChanges(); + }); + + it("shows loading text", () => { + const element = fixture.nativeElement; + expect(element.textContent.trim()).toBe("openingExtension"); + }); + }); + + describe("error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Error); + fixture.detectChanges(); + }); + + it("shows error text", () => { + const errorText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(errorText.textContent.trim()).toBe("openingExtensionError"); + }); + }); + + describe("success state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.Success); + fixture.detectChanges(); + }); + + it("shows success message", () => { + const successText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(successText.textContent.trim()).toBe("openedExtensionViewAtRiskPasswords"); + }); + }); + + describe("mobile state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.MobileBrowser); + fixture.detectChanges(); + }); + + it("shows mobile message", () => { + const mobileText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(mobileText.textContent.trim()).toBe("reopenLinkOnDesktop"); + }); + }); + + describe("manual error state", () => { + beforeEach(() => { + pageState$.next(BrowserPromptState.ManualOpen); + fixture.detectChanges(); + }); + + it("shows manual open error message", () => { + const manualText = fixture.debugElement.query(By.css("p")).nativeElement; + expect(manualText.textContent.trim()).toContain("openExtensionManuallyPart1"); + expect(manualText.textContent.trim()).toContain("openExtensionManuallyPart2"); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts new file mode 100644 index 00000000000..640a1b0d771 --- /dev/null +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; + +import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultIcons } from "@bitwarden/vault"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "../../services/browser-extension-prompt.service"; + +@Component({ + selector: "vault-browser-extension-prompt", + templateUrl: "./browser-extension-prompt.component.html", + standalone: true, + imports: [CommonModule, I18nPipe, ButtonComponent, IconModule], +}) +export class BrowserExtensionPromptComponent implements OnInit { + /** Current state of the prompt page */ + protected pageState$ = this.browserExtensionPromptService.pageState$; + + /** All available page states */ + protected BrowserPromptState = BrowserPromptState; + + protected BitwardenIcon = VaultIcons.BitwardenIcon; + + constructor(private browserExtensionPromptService: BrowserExtensionPromptService) {} + + ngOnInit(): void { + this.browserExtensionPromptService.start(); + } + + openExtension(): void { + this.browserExtensionPromptService.openExtension(); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts index 62abc0c0b34..1a767bc8964 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts @@ -16,7 +16,7 @@ import { StateProvider } from "@bitwarden/common/platform/state"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service"; import { VaultOnboardingComponent } from "./vault-onboarding.component"; @@ -158,7 +158,7 @@ describe("VaultOnboardingComponent", () => { it("should call getMessages when showOnboarding is true", () => { const messageEventSubject = new Subject(); const messageEvent = new MessageEvent("message", { - data: VaultOnboardingMessages.HasBwInstalled, + data: VaultMessages.HasBwInstalled, }); const getMessagesSpy = jest.spyOn(component, "getMessages"); @@ -168,7 +168,7 @@ describe("VaultOnboardingComponent", () => { void fixture.whenStable().then(() => { expect(window.postMessage).toHaveBeenCalledWith({ - command: VaultOnboardingMessages.checkBwInstalled, + command: VaultMessages.checkBwInstalled, }); expect(getMessagesSpy).toHaveBeenCalled(); }); @@ -188,7 +188,7 @@ describe("VaultOnboardingComponent", () => { installExtension: false, }); }); - const eventData = { data: { command: VaultOnboardingMessages.HasBwInstalled } }; + const eventData = { data: { command: VaultMessages.HasBwInstalled } }; (component as any).showOnboarding = true; diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index 4b69e3977c6..dc4a014073a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -24,7 +24,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LinkModule } from "@bitwarden/components"; @@ -106,12 +106,12 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { void this.getMessages(event); }); - window.postMessage({ command: VaultOnboardingMessages.checkBwInstalled }); + window.postMessage({ command: VaultMessages.checkBwInstalled }); } } async getMessages(event: any) { - if (event.data.command === VaultOnboardingMessages.HasBwInstalled && this.showOnboarding) { + if (event.data.command === VaultMessages.HasBwInstalled && this.showOnboarding) { const currentTasks = await firstValueFrom(this.onboardingTasks$); const updatedTasks = { createAccount: currentTasks.createAccount, diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts new file mode 100644 index 00000000000..647af007eca --- /dev/null +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.spec.ts @@ -0,0 +1,173 @@ +import { TestBed } from "@angular/core/testing"; + +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +import { + BrowserExtensionPromptService, + BrowserPromptState, +} from "./browser-extension-prompt.service"; + +describe("BrowserExtensionPromptService", () => { + let service: BrowserExtensionPromptService; + const setAnonLayoutWrapperData = jest.fn(); + const isFirefox = jest.fn().mockReturnValue(false); + const postMessage = jest.fn(); + window.postMessage = postMessage; + + beforeEach(() => { + setAnonLayoutWrapperData.mockClear(); + postMessage.mockClear(); + isFirefox.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + BrowserExtensionPromptService, + { provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } }, + { provide: PlatformUtilsService, useValue: { isFirefox } }, + ], + }); + jest.useFakeTimers(); + service = TestBed.inject(BrowserExtensionPromptService); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it("defaults page state to loading", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Loading); + done(); + }); + }); + + describe("start", () => { + it("posts message to check for extension", () => { + service.start(); + + expect(window.postMessage).toHaveBeenCalledWith({ + command: VaultMessages.checkBwInstalled, + }); + }); + + it("sets timeout for error state", () => { + service.start(); + + expect(service["extensionCheckTimeout"]).not.toBeNull(); + }); + + it("attempts to open the extension when installed", () => { + service.start(); + + window.dispatchEvent( + new MessageEvent("message", { data: { command: VaultMessages.HasBwInstalled } }), + ); + + expect(window.postMessage).toHaveBeenCalledTimes(2); + expect(window.postMessage).toHaveBeenCalledWith({ command: VaultMessages.OpenPopup }); + }); + }); + + describe("success state", () => { + beforeEach(() => { + service.start(); + + window.dispatchEvent( + new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }), + ); + }); + + it("sets layout title", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "openedExtension" }, + }); + }); + + it("sets success page state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Success); + done(); + }); + }); + + it("clears the error timeout", () => { + expect(service["extensionCheckTimeout"]).toBeUndefined(); + }); + }); + + describe("firefox", () => { + beforeEach(() => { + isFirefox.mockReturnValue(true); + service.start(); + }); + + afterEach(() => { + isFirefox.mockReturnValue(false); + }); + + it("sets manual open state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.ManualOpen); + done(); + }); + }); + + it("sets error state after timeout", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "somethingWentWrong" }, + }); + }); + }); + + describe("mobile state", () => { + beforeEach(() => { + Utils.isMobileBrowser = true; + service.start(); + }); + + afterEach(() => { + Utils.isMobileBrowser = false; + }); + + it("sets mobile state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.MobileBrowser); + done(); + }); + }); + + it("sets desktop required title", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "desktopRequired" }, + }); + }); + + it("clears the error timeout", () => { + expect(service["extensionCheckTimeout"]).toBeUndefined(); + }); + }); + + describe("error state", () => { + beforeEach(() => { + service.start(); + jest.advanceTimersByTime(1000); + }); + + it("sets error state", (done) => { + service.pageState$.subscribe((state) => { + expect(state).toBe(BrowserPromptState.Error); + done(); + }); + }); + + it("sets error state after timeout", () => { + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "somethingWentWrong" }, + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/browser-extension-prompt.service.ts b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts new file mode 100644 index 00000000000..fec27758d3c --- /dev/null +++ b/apps/web/src/app/vault/services/browser-extension-prompt.service.ts @@ -0,0 +1,125 @@ +import { DestroyRef, Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, fromEvent } from "rxjs"; + +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; + +export enum BrowserPromptState { + Loading = "loading", + Error = "error", + Success = "success", + ManualOpen = "manualOpen", + MobileBrowser = "mobileBrowser", +} + +type PromptErrorStates = BrowserPromptState.Error | BrowserPromptState.ManualOpen; + +@Injectable({ + providedIn: "root", +}) +export class BrowserExtensionPromptService { + private _pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + + /** Current state of the prompt page */ + pageState$ = this._pageState$.asObservable(); + + /** Timeout identifier for extension check */ + private extensionCheckTimeout: number | undefined; + + constructor( + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private destroyRef: DestroyRef, + private platformUtilsService: PlatformUtilsService, + ) {} + + start(): void { + if (Utils.isMobileBrowser) { + this.setMobileState(); + return; + } + + // Firefox does not support automatically opening the extension, + // it currently requires a user gesture within the context of the extension to open. + // Show message to direct the user to manually open the extension. + // Mozilla Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1799344 + if (this.platformUtilsService.isFirefox()) { + this.setErrorState(BrowserPromptState.ManualOpen); + return; + } + + this.checkForBrowserExtension(); + } + + /** Post a message to the extension to open */ + openExtension() { + window.postMessage({ command: VaultMessages.OpenPopup }); + } + + /** Send message checking for the browser extension */ + private checkForBrowserExtension() { + fromEvent(window, "message") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + void this.getMessages(event); + }); + + window.postMessage({ command: VaultMessages.checkBwInstalled }); + + // Wait a second for the extension to respond and open, else show the error state + this.extensionCheckTimeout = window.setTimeout(() => { + this.setErrorState(); + }, 1000); + } + + /** Handle window message events */ + private getMessages(event: any) { + if (event.data.command === VaultMessages.HasBwInstalled) { + this.openExtension(); + } + + if (event.data.command === VaultMessages.PopupOpened) { + this.setSuccessState(); + } + } + + /** Show message that this page should be opened on a desktop browser */ + private setMobileState() { + this.clearExtensionCheckTimeout(); + this._pageState$.next(BrowserPromptState.MobileBrowser); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "desktopRequired", + }, + }); + } + + /** Show the open extension success state */ + private setSuccessState() { + this.clearExtensionCheckTimeout(); + this._pageState$.next(BrowserPromptState.Success); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "openedExtension", + }, + }); + } + + /** Show open extension error state */ + private setErrorState(errorState?: PromptErrorStates) { + this.clearExtensionCheckTimeout(); + this._pageState$.next(errorState ?? BrowserPromptState.Error); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "somethingWentWrong", + }, + }); + } + + private clearExtensionCheckTimeout() { + window.clearTimeout(this.extensionCheckTimeout); + this.extensionCheckTimeout = undefined; + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c241003e7a..2ae1b8905ab 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9275,7 +9275,12 @@ }, "deviceManagementDesc":{ "message": "Configure device management for Bitwarden using the implementation guide for your platform." - + }, + "desktopRequired": { + "message": "Desktop required" + }, + "reopenLinkOnDesktop": { + "message": "Reopen this link from your email on a desktop." }, "integrationCardTooltip":{ "message": "Launch $INTEGRATION$ implementation guide.", @@ -10270,6 +10275,38 @@ "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." }, + "openingExtension": { + "message": "Opening the Bitwarden browser extension" + }, + "somethingWentWrong":{ + "message": "Something went wrong..." + }, + "openingExtensionError": { + "message": "We had trouble opening the Bitwarden browser extension. Click the button to open it now." + }, + "openExtension": { + "message": "Open extension" + }, + "doNotHaveExtension": { + "message": "Don't have the Bitwarden browser extension?" + }, + "installExtension": { + "message": "Install extension" + }, + "openedExtension": { + "message": "Opened the browser extension" + }, + "openedExtensionViewAtRiskPasswords": { + "message": "Successfully opened the Bitwarden browser extension. You can now review your at-risk passwords." + }, + "openExtensionManuallyPart1": { + "message": "We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon", + "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'" + }, + "openExtensionManuallyPart2": { + "message": "from the toolbar.", + "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'" + }, "resellerRenewalWarningMsg": { "message": "Your subscription will renew soon. To ensure uninterrupted service, contact $RESELLER$ to confirm your renewal before $RENEWAL_DATE$.", "placeholders": { diff --git a/libs/common/src/vault/enums/vault-messages.enum.ts b/libs/common/src/vault/enums/vault-messages.enum.ts new file mode 100644 index 00000000000..4cc038be849 --- /dev/null +++ b/libs/common/src/vault/enums/vault-messages.enum.ts @@ -0,0 +1,8 @@ +const VaultMessages = { + HasBwInstalled: "hasBwInstalled", + checkBwInstalled: "checkIfBWExtensionInstalled", + OpenPopup: "openPopup", + PopupOpened: "popupOpened", +} as const; + +export { VaultMessages }; diff --git a/libs/common/src/vault/enums/vault-onboarding.enum.ts b/libs/common/src/vault/enums/vault-onboarding.enum.ts deleted file mode 100644 index 11e072b3284..00000000000 --- a/libs/common/src/vault/enums/vault-onboarding.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -const VaultOnboardingMessages = { - HasBwInstalled: "hasBwInstalled", - checkBwInstalled: "checkIfBWExtensionInstalled", -} as const; - -export { VaultOnboardingMessages }; diff --git a/libs/vault/src/icons/bitwarden-icon.ts b/libs/vault/src/icons/bitwarden-icon.ts new file mode 100644 index 00000000000..73e4304d5c7 --- /dev/null +++ b/libs/vault/src/icons/bitwarden-icon.ts @@ -0,0 +1,20 @@ +import { svgIcon } from "@bitwarden/components"; + +export const BitwardenIcon = svgIcon` + + + + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/icons/browser-extension.ts b/libs/vault/src/icons/browser-extension.ts new file mode 100644 index 00000000000..f0f9b781491 --- /dev/null +++ b/libs/vault/src/icons/browser-extension.ts @@ -0,0 +1,17 @@ +import { svgIcon } from "@bitwarden/components"; + +export const BrowserExtensionIcon = svgIcon` + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/icons/index.ts b/libs/vault/src/icons/index.ts index 2e106782f53..e0be5e637f0 100644 --- a/libs/vault/src/icons/index.ts +++ b/libs/vault/src/icons/index.ts @@ -4,3 +4,5 @@ export * from "./vault"; export * from "./empty-trash"; export * from "./exclamation-triangle"; export * from "./user-lock"; +export * from "./browser-extension"; +export * from "./bitwarden-icon";