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";