diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/at-risk-notification/container.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/at-risk-notification/container.lit-stories.ts
index 55d35869db4..d0d29bd7dd8 100644
--- a/apps/browser/src/autofill/content/components/lit-stories/notification/at-risk-notification/container.lit-stories.ts
+++ b/apps/browser/src/autofill/content/components/lit-stories/notification/at-risk-notification/container.lit-stories.ts
@@ -3,7 +3,7 @@ import { Meta, StoryObj } from "@storybook/web-components";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { NotificationTypes } from "../../../../../notification/abstractions/notification-bar";
-import { getNotificationTestId } from "../../../../../notification/bar";
+import { getNotificationTestId } from "../../../../../notification/notification-bar-helpers";
import {
AtRiskNotification,
AtRiskNotificationProps,
diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation/container.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation/container.lit-stories.ts
index 55c0a456732..7d99bfa0d58 100644
--- a/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation/container.lit-stories.ts
+++ b/apps/browser/src/autofill/content/components/lit-stories/notification/confirmation/container.lit-stories.ts
@@ -6,7 +6,7 @@ import { NotificationTypes } from "../../../../../notification/abstractions/noti
import {
getConfirmationHeaderMessage,
getNotificationTestId,
-} from "../../../../../notification/bar";
+} from "../../../../../notification/notification-bar-helpers";
import {
NotificationConfirmationContainer,
NotificationConfirmationContainerProps,
diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/container.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/container.lit-stories.ts
index d09bc76c834..143ad2daf7b 100644
--- a/apps/browser/src/autofill/content/components/lit-stories/notification/container.lit-stories.ts
+++ b/apps/browser/src/autofill/content/components/lit-stories/notification/container.lit-stories.ts
@@ -5,7 +5,10 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { NotificationTypes } from "../../../../notification/abstractions/notification-bar";
-import { getNotificationHeaderMessage, getNotificationTestId } from "../../../../notification/bar";
+import {
+ getNotificationHeaderMessage,
+ getNotificationTestId,
+} from "../../../../notification/notification-bar-helpers";
import { NotificationContainer, NotificationContainerProps } from "../../notification/container";
import { mockBrowserI18nGetMessage, mockI18n } from "../mock-data";
diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts
index ee711456e9c..a5d6b6e43b6 100644
--- a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts
+++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts
@@ -160,7 +160,9 @@ declare global {
}
}
-export default customElements.define(optionSelectionTagName, OptionSelection);
+if (!globalThis.customElements?.get(optionSelectionTagName)) {
+ globalThis.customElements?.define(optionSelectionTagName, OptionSelection);
+}
function getDefaultOption(options: Option[] = []) {
return options.find((option: Option) => option.default) || options[0];
diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html
deleted file mode 100644
index 8934fe6a031..00000000000
--- a/apps/browser/src/autofill/notification/bar.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Bitwarden
-
-
-
-
diff --git a/apps/browser/src/autofill/notification/bar.spec.ts b/apps/browser/src/autofill/notification/bar.spec.ts
deleted file mode 100644
index ae60e2efc91..00000000000
--- a/apps/browser/src/autofill/notification/bar.spec.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { mock } from "jest-mock-extended";
-
-import { postWindowMessage } from "../spec/testing-utils";
-
-import { NotificationBarWindowMessage } from "./abstractions/notification-bar";
-import "./bar";
-
-jest.mock("lit", () => ({ render: jest.fn() }));
-jest.mock("@lit-labs/signals", () => ({
- signal: jest.fn((testValue) => ({ get: (): typeof testValue => testValue })),
-}));
-jest.mock("../content/components/notification/container", () => ({
- NotificationContainer: jest.fn(),
-}));
-
-describe("NotificationBar iframe handleWindowMessage security", () => {
- const trustedOrigin = "http://localhost";
- const maliciousOrigin = "https://malicious.com";
-
- const createMessage = (
- overrides: Partial = {},
- ): NotificationBarWindowMessage => ({
- command: "initNotificationBar",
- ...overrides,
- });
-
- beforeEach(() => {
- Object.defineProperty(globalThis, "location", {
- value: { search: `?parentOrigin=${encodeURIComponent(trustedOrigin)}` },
- writable: true,
- configurable: true,
- });
- Object.defineProperty(globalThis, "parent", {
- value: mock(),
- writable: true,
- configurable: true,
- });
- globalThis.dispatchEvent(new Event("load"));
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it.each([
- {
- description: "not from parent window",
- message: () => createMessage(),
- origin: trustedOrigin,
- source: () => mock(),
- },
- {
- description: "with mismatched origin",
- message: () => createMessage(),
- origin: maliciousOrigin,
- source: () => globalThis.parent,
- },
- {
- description: "without command field",
- message: () => ({}),
- origin: trustedOrigin,
- source: () => globalThis.parent,
- },
- {
- description: "initNotificationBar with mismatched parentOrigin",
- message: () => createMessage({ parentOrigin: maliciousOrigin }),
- origin: trustedOrigin,
- source: () => globalThis.parent,
- },
- {
- description: "when windowMessageOrigin is not set",
- message: () => createMessage(),
- origin: "different-origin",
- source: () => globalThis.parent,
- resetOrigin: true,
- },
- {
- description: "with null source",
- message: () => createMessage(),
- origin: trustedOrigin,
- source: (): null => null,
- },
- {
- description: "with unknown command",
- message: () => createMessage({ command: "unknownCommand" }),
- origin: trustedOrigin,
- source: () => globalThis.parent,
- },
- ])("should reject messages $description", ({ message, origin, source, resetOrigin }) => {
- if (resetOrigin) {
- Object.defineProperty(globalThis, "location", {
- value: { search: "" },
- writable: true,
- configurable: true,
- });
- }
- const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation();
- postWindowMessage(message(), origin, source());
- expect(spy).not.toHaveBeenCalled();
- });
-
- it("should accept and handle valid trusted messages", () => {
- const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation();
- spy.mockClear();
-
- const validMessage = createMessage({
- parentOrigin: trustedOrigin,
- initData: {
- type: "change",
- isVaultLocked: false,
- removeIndividualVault: false,
- importType: null,
- launchTimestamp: Date.now(),
- },
- });
- postWindowMessage(validMessage, trustedOrigin, globalThis.parent);
- expect(validMessage.command).toBe("initNotificationBar");
- expect(validMessage.parentOrigin).toBe(trustedOrigin);
- expect(validMessage.initData).toBeDefined();
- });
-});
diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts
deleted file mode 100644
index 333f8d5e534..00000000000
--- a/apps/browser/src/autofill/notification/bar.ts
+++ /dev/null
@@ -1,457 +0,0 @@
-import { render } from "lit";
-
-import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
-import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
-import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
-
-import { NotificationCipherData } from "../content/components/cipher/types";
-import { CollectionView, I18n, OrgView } from "../content/components/common-types";
-import { AtRiskNotification } from "../content/components/notification/at-risk-password/container";
-import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container";
-import { NotificationContainer } from "../content/components/notification/container";
-import { selectedCipher as selectedCipherSignal } from "../content/components/signals/selected-cipher";
-import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
-import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault";
-
-import {
- NotificationBarWindowMessageHandlers,
- NotificationBarWindowMessage,
- NotificationBarIframeInitData,
- NotificationType,
- NotificationTypes,
-} from "./abstractions/notification-bar";
-
-let notificationBarIframeInitData: NotificationBarIframeInitData = {};
-let windowMessageOrigin: string;
-
-const urlParams = new URLSearchParams(globalThis.location.search);
-const trustedParentOrigin = urlParams.get("parentOrigin");
-
-if (trustedParentOrigin) {
- windowMessageOrigin = trustedParentOrigin;
-}
-
-const notificationBarWindowMessageHandlers: NotificationBarWindowMessageHandlers = {
- initNotificationBar: ({ message }) => initNotificationBar(message),
- saveCipherAttemptCompleted: ({ message }) => handleSaveCipherConfirmation(message),
-};
-
-globalThis.addEventListener("load", load);
-
-function load() {
- setupWindowMessageListener();
- postMessageToParent({ command: "initNotificationBar" });
-}
-
-function getI18n() {
- return {
- appName: chrome.i18n.getMessage("appName"),
- atRiskPassword: chrome.i18n.getMessage("atRiskPassword"),
- changePassword: chrome.i18n.getMessage("changePassword"),
- close: chrome.i18n.getMessage("close"),
- collection: chrome.i18n.getMessage("collection"),
- folder: chrome.i18n.getMessage("folder"),
- loginSaveConfirmation: chrome.i18n.getMessage("loginSaveConfirmation"),
- loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"),
- loginUpdatedConfirmation: chrome.i18n.getMessage("loginUpdatedConfirmation"),
- loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"),
- loginUpdateTaskSuccess: chrome.i18n.getMessage("loginUpdateTaskSuccess"),
- loginUpdateTaskSuccessAdditional: chrome.i18n.getMessage("loginUpdateTaskSuccessAdditional"),
- myVault: chrome.i18n.getMessage("myVault"),
- never: chrome.i18n.getMessage("never"),
- newItem: chrome.i18n.getMessage("newItem"),
- nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
- notificationAddDesc: chrome.i18n.getMessage("notificationAddDesc"),
- notificationAddSave: chrome.i18n.getMessage("notificationAddSave"),
- notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"),
- notificationEdit: chrome.i18n.getMessage("edit"),
- notificationEditTooltip: chrome.i18n.getMessage("notificationEditTooltip"),
- notificationLoginSaveConfirmation: chrome.i18n.getMessage("notificationLoginSaveConfirmation"),
- notificationLoginUpdatedConfirmation: chrome.i18n.getMessage(
- "notificationLoginUpdatedConfirmation",
- ),
- notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
- notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"),
- notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
- notificationViewAria: chrome.i18n.getMessage("notificationViewAria"),
- saveAction: chrome.i18n.getMessage("notificationAddSave"),
- saveAsNewLoginAction: chrome.i18n.getMessage("saveAsNewLoginAction"),
- saveFailure: chrome.i18n.getMessage("saveFailure"),
- saveFailureDetails: chrome.i18n.getMessage("saveFailureDetails"),
- saveLogin: chrome.i18n.getMessage("saveLogin"),
- typeLogin: chrome.i18n.getMessage("typeLogin"),
- unlockToSave: chrome.i18n.getMessage("unlockToSave"),
- updateLogin: chrome.i18n.getMessage("updateLogin"),
- updateLoginAction: chrome.i18n.getMessage("updateLoginAction"),
- vault: chrome.i18n.getMessage("vault"),
- view: chrome.i18n.getMessage("view"),
- };
-}
-
-/**
- * Returns the localized header message for the notification bar based on the notification type.
- *
- * @returns The localized header message string, or undefined if the type is not recognized.
- */
-export function getNotificationHeaderMessage(i18n: I18n, type?: NotificationType) {
- return type
- ? {
- [NotificationTypes.Add]: i18n.saveLogin,
- [NotificationTypes.Change]: i18n.updateLogin,
- [NotificationTypes.Unlock]: i18n.unlockToSave,
- [NotificationTypes.AtRiskPassword]: i18n.atRiskPassword,
- }[type]
- : undefined;
-}
-
-/**
- * Returns the localized header message for the confirmation message bar based on the notification type.
- *
- * @returns The localized header message string, or undefined if the type is not recognized.
- */
-export function getConfirmationHeaderMessage(i18n: I18n, type?: NotificationType, error?: string) {
- if (error) {
- return i18n.saveFailure;
- }
-
- return type
- ? {
- [NotificationTypes.Add]: i18n.loginSaveSuccess,
- [NotificationTypes.Change]: i18n.loginUpdateSuccess,
- [NotificationTypes.Unlock]: "",
- [NotificationTypes.AtRiskPassword]: "",
- }[type]
- : undefined;
-}
-
-/**
- * Appends the header message to the document title.
- * If the header message is already present, it avoids duplication.
- */
-export function appendHeaderMessageToTitle(headerMessage?: string) {
- if (!headerMessage) {
- return;
- }
- const baseTitle = document.title.split(" - ")[0];
- document.title = `${baseTitle} - ${headerMessage}`;
-}
-
-/**
- * Determines the effective notification type to use based on initialization data.
- *
- * If the vault is locked, the notification type will be set to `Unlock`.
- * Otherwise, the type provided in the init data is returned.
- *
- * @returns The resolved `NotificationType` to be used for rendering logic.
- */
-function resolveNotificationType(initData: NotificationBarIframeInitData): NotificationType {
- if (initData.isVaultLocked) {
- return NotificationTypes.Unlock;
- }
-
- return initData.type as NotificationType;
-}
-
-/**
- * Returns the appropriate test ID based on the resolved notification type.
- *
- * @param type - The resolved NotificationType.
- * @param isConfirmation - Optional flag for confirmation vs. notification container.
- */
-export function getNotificationTestId(
- notificationType: NotificationType,
- isConfirmation = false,
-): string {
- if (isConfirmation) {
- return "confirmation-notification-bar";
- }
-
- return {
- [NotificationTypes.Unlock]: "unlock-notification-bar",
- [NotificationTypes.Add]: "save-notification-bar",
- [NotificationTypes.Change]: "update-notification-bar",
- [NotificationTypes.AtRiskPassword]: "at-risk-notification-bar",
- }[notificationType];
-}
-
-async function initNotificationBar(message: NotificationBarWindowMessage) {
- const { initData } = message;
- if (!initData) {
- return;
- }
-
- notificationBarIframeInitData = initData;
- const {
- isVaultLocked,
- removeIndividualVault: personalVaultDisallowed,
- theme,
- } = notificationBarIframeInitData;
- const i18n = getI18n();
- const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light);
-
- const notificationType = resolveNotificationType(notificationBarIframeInitData);
- const headerMessage = getNotificationHeaderMessage(i18n, notificationType);
- const notificationTestId = getNotificationTestId(notificationType);
- appendHeaderMessageToTitle(headerMessage);
-
- if (isVaultLocked) {
- const notificationConfig = {
- ...notificationBarIframeInitData,
- headerMessage,
- type: notificationType,
- notificationTestId,
- theme: resolvedTheme,
- personalVaultIsAllowed: !personalVaultDisallowed,
- handleCloseNotification,
- handleEditOrUpdateAction,
- i18n,
- };
-
- const handleSaveAction = () => {
- // cipher ID is null while vault is locked.
- sendSaveCipherMessage(null, true);
-
- render(
- NotificationContainer({
- ...notificationConfig,
- handleSaveAction: () => {},
- isLoading: true,
- }),
- document.body,
- );
- };
-
- const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
-
- return render(UnlockNotification, document.body);
- }
-
- // Handle AtRiskPasswordNotification render
- if (notificationBarIframeInitData.type === NotificationTypes.AtRiskPassword) {
- return render(
- AtRiskNotification({
- ...notificationBarIframeInitData,
- type: notificationBarIframeInitData.type as NotificationType,
- theme: resolvedTheme,
- i18n,
- notificationTestId,
- params: initData.params,
- handleCloseNotification,
- }),
- document.body,
- );
- }
-
- // Default scenario: add or update password
- const orgId = selectedVaultSignal.get();
-
- await Promise.all([
- new Promise((resolve) => sendPlatformMessage({ command: "bgGetOrgData" }, resolve)),
- new Promise((resolve) =>
- sendPlatformMessage({ command: "bgGetFolderData" }, resolve),
- ),
- new Promise((resolve) =>
- sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
- ),
- new Promise((resolve) =>
- sendPlatformMessage({ command: "bgGetCollectionData", orgId }, resolve),
- ),
- ]).then(([organizations, folders, ciphers, collections]) => {
- notificationBarIframeInitData = {
- ...notificationBarIframeInitData,
- organizations,
- folders,
- ciphers,
- collections,
- };
-
- // @TODO use context to avoid prop drilling
- return render(
- NotificationContainer({
- ...notificationBarIframeInitData,
- headerMessage,
- type: notificationType,
- theme: resolvedTheme,
- notificationTestId,
- personalVaultIsAllowed: !personalVaultDisallowed,
- handleCloseNotification,
- handleSaveAction,
- handleEditOrUpdateAction,
- i18n,
- }),
- document.body,
- );
- });
-
- function handleEditOrUpdateAction(e: Event) {
- e.preventDefault();
- sendSaveCipherMessage(selectedCipherSignal.get(), notificationType === NotificationTypes.Add);
- }
-}
-
-function handleCloseNotification(e: Event) {
- e.preventDefault();
- sendPlatformMessage({
- command: "bgCloseNotificationBar",
- fadeOutNotification: true,
- });
-}
-
-function handleSaveAction(e: Event) {
- const selectedCipher = selectedCipherSignal.get();
- const selectedVault = selectedVaultSignal.get();
- const selectedFolder = selectedFolderSignal.get();
-
- if (selectedVault.length > 1) {
- openAddEditVaultItemPopout(e, {
- organizationId: selectedVault,
- ...(selectedFolder?.length > 1 ? { folder: selectedFolder } : {}),
- });
- handleCloseNotification(e);
- return;
- }
-
- e.preventDefault();
- sendSaveCipherMessage(selectedCipher, removeIndividualVault(), selectedFolder);
- if (removeIndividualVault()) {
- return;
- }
-}
-
-function sendSaveCipherMessage(cipherId: CipherView["id"] | null, edit: boolean, folder?: string) {
- sendPlatformMessage({
- command: "bgSaveCipher",
- cipherId,
- folder,
- edit,
- });
-}
-
-function openAddEditVaultItemPopout(
- e: Event,
- options: {
- cipherId?: string;
- organizationId?: string;
- folder?: string;
- },
-) {
- e.preventDefault();
- sendPlatformMessage({
- command: "bgOpenAddEditVaultItemPopout",
- ...options,
- });
-}
-
-function openViewVaultItemPopout(cipherId: string) {
- sendPlatformMessage({
- command: "bgOpenViewVaultItemPopout",
- cipherId,
- });
-}
-
-function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
- const { theme, type } = notificationBarIframeInitData;
- const { error, data } = message;
- const { cipherId, task, itemName } = data || {};
- const i18n = getI18n();
- const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light);
- const resolvedType = resolveNotificationType(notificationBarIframeInitData);
- const headerMessage = getConfirmationHeaderMessage(i18n, resolvedType, error);
- const notificationTestId = getNotificationTestId(resolvedType, true);
- appendHeaderMessageToTitle(headerMessage);
-
- globalThis.setTimeout(() => sendPlatformMessage({ command: "bgCloseNotificationBar" }), 5000);
-
- return render(
- NotificationConfirmationContainer({
- ...notificationBarIframeInitData,
- error,
- handleCloseNotification,
- handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRiskPasswords" }),
- handleOpenVault: (e: Event) =>
- cipherId ? openViewVaultItemPopout(cipherId) : openAddEditVaultItemPopout(e, {}),
- headerMessage,
- i18n,
- itemName: itemName ?? i18n.typeLogin,
- notificationTestId,
- task,
- theme: resolvedTheme,
- type: type as NotificationType,
- }),
- document.body,
- );
-}
-
-function sendPlatformMessage(
- msg: Record,
- responseCallback?: (response: any) => void,
-) {
- chrome.runtime.sendMessage(msg, (response) => {
- if (responseCallback) {
- responseCallback(response);
- }
- });
-}
-
-function removeIndividualVault(): boolean {
- return Boolean(notificationBarIframeInitData?.removeIndividualVault);
-}
-
-function setupWindowMessageListener() {
- globalThis.addEventListener("message", handleWindowMessage);
-}
-
-function handleWindowMessage(event: MessageEvent) {
- if (event?.source !== globalThis.parent) {
- return;
- }
-
- const message = event.data as NotificationBarWindowMessage;
- if (!message?.command) {
- return;
- }
-
- if (!windowMessageOrigin || event.origin !== windowMessageOrigin) {
- return;
- }
-
- if (
- message.command === "initNotificationBar" &&
- message.parentOrigin &&
- message.parentOrigin !== event.origin
- ) {
- return;
- }
-
- const handler = notificationBarWindowMessageHandlers[message.command];
- if (!handler) {
- return;
- }
-
- handler({ message });
-}
-
-function getTheme(globalThis: any, theme: NotificationBarIframeInitData["theme"]) {
- if (theme === ThemeTypes.System) {
- return globalThis.matchMedia("(prefers-color-scheme: dark)").matches
- ? ThemeTypes.Dark
- : ThemeTypes.Light;
- }
-
- return theme;
-}
-
-function getResolvedTheme(theme: Theme) {
- const themeType = getTheme(globalThis, theme);
-
- // There are other possible passed theme values, but for now, resolve to dark or light
- const resolvedTheme: Theme = themeType === ThemeTypes.Dark ? ThemeTypes.Dark : ThemeTypes.Light;
- return resolvedTheme;
-}
-
-function postMessageToParent(message: NotificationBarWindowMessage) {
- if (!windowMessageOrigin) {
- return;
- }
- globalThis.parent.postMessage(message, windowMessageOrigin);
-}
diff --git a/apps/browser/src/autofill/notification/notification-bar-helpers.ts b/apps/browser/src/autofill/notification/notification-bar-helpers.ts
new file mode 100644
index 00000000000..4330b76e54a
--- /dev/null
+++ b/apps/browser/src/autofill/notification/notification-bar-helpers.ts
@@ -0,0 +1,64 @@
+import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
+
+import { I18n } from "../content/components/common-types";
+
+import {
+ NotificationBarIframeInitData,
+ NotificationType,
+ NotificationTypes,
+} from "./abstractions/notification-bar";
+
+export function getNotificationHeaderMessage(i18n: I18n, type?: NotificationType) {
+ if (!type) {
+ return undefined;
+ }
+ const messages = {
+ [NotificationTypes.Add]: i18n.saveLogin,
+ [NotificationTypes.Change]: i18n.updateLogin,
+ [NotificationTypes.Unlock]: i18n.unlockToSave,
+ [NotificationTypes.AtRiskPassword]: i18n.atRiskPassword,
+ };
+ return messages[type];
+}
+
+export function getConfirmationHeaderMessage(i18n: I18n, type?: NotificationType, error?: string) {
+ if (error) {
+ return i18n.saveFailure;
+ }
+ if (!type) {
+ return undefined;
+ }
+ const messages = {
+ [NotificationTypes.Add]: i18n.loginSaveSuccess,
+ [NotificationTypes.Change]: i18n.loginUpdateSuccess,
+ [NotificationTypes.Unlock]: "",
+ [NotificationTypes.AtRiskPassword]: "",
+ };
+ return messages[type];
+}
+
+export function getNotificationTestId(notificationType: NotificationType, isConfirmation = false) {
+ if (isConfirmation) {
+ return "confirmation-notification-bar";
+ }
+ const testIds = {
+ [NotificationTypes.Unlock]: "unlock-notification-bar",
+ [NotificationTypes.Add]: "save-notification-bar",
+ [NotificationTypes.Change]: "update-notification-bar",
+ [NotificationTypes.AtRiskPassword]: "at-risk-notification-bar",
+ };
+ return testIds[notificationType];
+}
+
+export function resolveNotificationType(initData: NotificationBarIframeInitData): NotificationType {
+ return initData.isVaultLocked ? NotificationTypes.Unlock : (initData.type as NotificationType);
+}
+
+export function getResolvedTheme(theme: Theme): Theme {
+ if (theme === ThemeTypes.System) {
+ return globalThis.matchMedia("(prefers-color-scheme: dark)").matches
+ ? ThemeTypes.Dark
+ : ThemeTypes.Light;
+ }
+ return theme === ThemeTypes.Dark ? ThemeTypes.Dark : ThemeTypes.Light;
+}
diff --git a/apps/browser/src/autofill/overlay/notifications/abstractions/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/abstractions/overlay-notifications-content.service.ts
index 338c79cc607..4a8ea7bed6b 100644
--- a/apps/browser/src/autofill/overlay/notifications/abstractions/overlay-notifications-content.service.ts
+++ b/apps/browser/src/autofill/overlay/notifications/abstractions/overlay-notifications-content.service.ts
@@ -1,5 +1,7 @@
import { Theme } from "@bitwarden/common/platform/enums";
+import { NotificationTaskInfo } from "../../../notification/abstractions/notification-bar";
+
export type NotificationTypeData = {
isVaultLocked?: boolean;
theme?: Theme;
@@ -8,19 +10,38 @@ export type NotificationTypeData = {
launchTimestamp?: number;
};
-export type NotificationsExtensionMessage = {
- command: string;
- data?: {
- type?: string;
- typeData?: NotificationTypeData;
- height?: number;
- error?: string;
- closedByUser?: boolean;
- fadeOutNotification?: boolean;
- params: object;
+type OpenNotificationBarMessage = {
+ command: "openNotificationBar";
+ data: {
+ type: string;
+ typeData: NotificationTypeData;
+ params?: object;
};
};
+type CloseNotificationBarMessage = {
+ command: "closeNotificationBar";
+ data?: {
+ closedByUser?: boolean;
+ fadeOutNotification?: boolean;
+ };
+};
+
+type SaveCipherAttemptCompletedMessage = {
+ command: "saveCipherAttemptCompleted";
+ data?: {
+ error?: string;
+ cipherId?: string;
+ task?: NotificationTaskInfo;
+ itemName?: string;
+ };
+};
+
+export type NotificationsExtensionMessage =
+ | OpenNotificationBarMessage
+ | CloseNotificationBarMessage
+ | SaveCipherAttemptCompletedMessage;
+
type OverlayNotificationsExtensionMessageParam = {
message: NotificationsExtensionMessage;
};
@@ -34,7 +55,6 @@ export type OverlayNotificationsExtensionMessageHandlers = {
[key: string]: ({ message, sender }: OverlayNotificationsExtensionMessageParams) => any;
openNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
closeNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
- adjustNotificationBar: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
saveCipherAttemptCompleted: ({ message }: OverlayNotificationsExtensionMessageParam) => void;
};
diff --git a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap
deleted file mode 100644
index cfcedc9da7a..00000000000
--- a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap
+++ /dev/null
@@ -1,14 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body within a shadow root 1`] = `
-
-
-
-`;
diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts
index 8dc08169468..946b13d4da8 100644
--- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts
+++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts
@@ -1,13 +1,29 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
-import { EVENTS } from "@bitwarden/common/autofill/constants";
+import { render } from "lit";
-import { BrowserApi } from "../../../../platform/browser/browser-api";
+import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
+
+import { I18n } from "../../../content/components/common-types";
+import { AtRiskNotification } from "../../../content/components/notification/at-risk-password/container";
+import { NotificationConfirmationContainer } from "../../../content/components/notification/confirmation/container";
+import { NotificationContainer } from "../../../content/components/notification/container";
+import { selectedCipher as selectedCipherSignal } from "../../../content/components/signals/selected-cipher";
+import { selectedFolder as selectedFolderSignal } from "../../../content/components/signals/selected-folder";
+import { selectedVault as selectedVaultSignal } from "../../../content/components/signals/selected-vault";
import {
NotificationBarIframeInitData,
+ NotificationTaskInfo,
NotificationType,
NotificationTypes,
} from "../../../notification/abstractions/notification-bar";
+import {
+ getConfirmationHeaderMessage,
+ getNotificationHeaderMessage,
+ getNotificationTestId,
+ getResolvedTheme,
+ resolveNotificationType,
+} from "../../../notification/notification-bar-helpers";
import { sendExtensionMessage, setElementStyles } from "../../../utils";
import {
NotificationsExtensionMessage,
@@ -15,55 +31,60 @@ import {
OverlayNotificationsExtensionMessageHandlers,
} from "../abstractions/overlay-notifications-content.service";
+const notificationBarContainerStyles: Partial = {
+ height: "400px",
+ width: "430px",
+ maxWidth: "calc(100% - 20px)",
+ minHeight: "initial",
+ top: "10px",
+ right: "0px",
+ padding: "0",
+ position: "fixed",
+ zIndex: "2147483647",
+ visibility: "visible",
+ borderRadius: "4px",
+ border: "none",
+ backgroundColor: "transparent",
+ overflow: "hidden",
+ transition: "box-shadow 0.15s ease, transform 0.15s ease-out, opacity 0.15s ease",
+ transitionDelay: "0.15s",
+ transform: "translateX(100%)",
+ opacity: "0",
+};
+
+const emotionStyleSelctor = "style[data-emotion], style[data-emotion-css]";
+const validNotificationTypes = new Set([
+ NotificationTypes.Add,
+ NotificationTypes.Change,
+ NotificationTypes.Unlock,
+ NotificationTypes.AtRiskPassword,
+]);
+
export class OverlayNotificationsContentService
implements OverlayNotificationsContentServiceInterface
{
private notificationBarRootElement: HTMLElement | null = null;
private notificationBarElement: HTMLElement | null = null;
- private notificationBarIframeElement: HTMLIFrameElement | null = null;
private notificationBarShadowRoot: ShadowRoot | null = null;
private currentNotificationBarType: NotificationType | null = null;
- private readonly extensionOrigin: string;
- private notificationBarContainerStyles: Partial = {
- height: "400px",
- width: "430px",
- maxWidth: "calc(100% - 20px)",
- minHeight: "initial",
- top: "10px",
- right: "0px",
- padding: "0",
- position: "fixed",
- zIndex: "2147483647",
- visibility: "visible",
- borderRadius: "4px",
- border: "none",
- backgroundColor: "transparent",
- overflow: "hidden",
- transition: "box-shadow 0.15s ease",
- transitionDelay: "0.15s",
- };
-
- private notificationBarIframeElementStyles: Partial = {
- width: "100%",
- height: "100%",
- border: "0",
- display: "block",
- position: "relative",
- transition: "transform 0.15s ease-out, opacity 0.15s ease",
- borderRadius: "4px",
- colorScheme: "auto",
+ private currentNotificationBarInitData: NotificationBarIframeInitData | null = null;
+ private mutationObserver: MutationObserver | null = null;
+ private foreignMutationsCount = 0;
+ private mutationObserverIterations = 0;
+ private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null;
+ private expectedContainerStyles: Partial = {};
+ private readonly defaultNotificationBarAttributes: Record = {
+ id: "bit-notification-bar",
};
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
openNotificationBar: ({ message }) => this.handleOpenNotificationBarMessage(message),
closeNotificationBar: ({ message }) => this.handleCloseNotificationBarMessage(message),
- adjustNotificationBar: ({ message }) => this.handleAdjustNotificationBarHeightMessage(message),
saveCipherAttemptCompleted: ({ message }) =>
this.handleSaveCipherAttemptCompletedMessage(message),
};
constructor() {
- this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1);
void sendExtensionMessage("checkNotificationQueue");
}
@@ -81,16 +102,20 @@ export class OverlayNotificationsContentService
* @param message - The message containing the initialization data for the notification bar.
*/
private async handleOpenNotificationBarMessage(message: NotificationsExtensionMessage) {
- if (!message.data) {
+ if (message.command !== "openNotificationBar" || !message.data) {
return;
}
const { type, typeData, params } = message.data;
+ if (!validNotificationTypes.has(type as NotificationType)) {
+ return;
+ }
+
if (this.currentNotificationBarType && type !== this.currentNotificationBarType) {
this.closeNotificationBar();
}
- const initData = {
+ const initData: NotificationBarIframeInitData = {
type: type as NotificationType,
isVaultLocked: typeData.isVaultLocked,
theme: typeData.theme,
@@ -101,7 +126,11 @@ export class OverlayNotificationsContentService
};
if (globalThis.document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", () => this.openNotificationBar(initData));
+ globalThis.document.addEventListener(
+ "DOMContentLoaded",
+ () => this.openNotificationBar(initData),
+ { once: true },
+ );
return;
}
@@ -115,10 +144,13 @@ export class OverlayNotificationsContentService
* @param message - The message containing the data for closing the notification bar.
*/
private handleCloseNotificationBarMessage(message: NotificationsExtensionMessage) {
+ if (message.command !== "closeNotificationBar") {
+ return;
+ }
const closedByUser =
typeof message.data?.closedByUser === "boolean" ? message.data.closedByUser : true;
if (message.data?.fadeOutNotification) {
- setElementStyles(this.notificationBarIframeElement, { opacity: "0" }, true);
+ this.setContainerStyles({ opacity: "0" });
globalThis.setTimeout(() => this.closeNotificationBar(closedByUser), 150);
return;
}
@@ -126,201 +158,524 @@ export class OverlayNotificationsContentService
this.closeNotificationBar(closedByUser);
}
- /**
- * Adjusts the height of the notification bar.
- *
- * @param message - The message containing the height of the notification bar.
- */
- private handleAdjustNotificationBarHeightMessage(message: NotificationsExtensionMessage) {
- if (this.notificationBarElement && message.data?.height) {
- this.notificationBarElement.style.height = `${message.data.height}px`;
- }
- }
-
- /**
- * Handles the message for when a save cipher attempt has completed. This triggers an update
- * to the presentation of the notification bar, facilitating a visual indication of the save
- * attempt's success or failure.
- *
- * @param message
- * @private
- */
private handleSaveCipherAttemptCompletedMessage(message: NotificationsExtensionMessage) {
- // destructure error out of data
- const { error, ...otherData } = message?.data || {};
-
- this.sendMessageToNotificationBarIframe({
- command: "saveCipherAttemptCompleted",
- data: Object.keys(otherData).length ? otherData : undefined,
- error,
- });
- }
-
- /**
- * Opens the notification bar with the given initialization data.
- *
- * @param initData
- * @private
- */
- private openNotificationBar(initData: NotificationBarIframeInitData) {
- if (!this.notificationBarRootElement && !this.notificationBarIframeElement) {
- this.createNotificationBarIframeElement(initData);
- this.createNotificationBarElement();
-
- this.setupInitNotificationBarMessageListener(initData);
- globalThis.document.body.appendChild(this.notificationBarRootElement);
- }
- }
-
- /**
- * Creates the iframe element for the notification bar.
- *
- * @param initData - The initialization data for the notification bar.
- */
- private createNotificationBarIframeElement(initData: NotificationBarIframeInitData) {
- const isNotificationFresh =
- initData.launchTimestamp && Date.now() - initData.launchTimestamp < 250;
-
- this.currentNotificationBarType = initData.type;
- this.notificationBarIframeElement = globalThis.document.createElement("iframe");
- this.notificationBarIframeElement.id = "bit-notification-bar-iframe";
- const parentOrigin = globalThis.location.origin;
- const iframeUrl = new URL(BrowserApi.getRuntimeURL("notification/bar.html"));
- iframeUrl.searchParams.set("parentOrigin", parentOrigin);
- this.notificationBarIframeElement.src = iframeUrl.toString();
- setElementStyles(
- this.notificationBarIframeElement,
- {
- ...this.notificationBarIframeElementStyles,
- transform: isNotificationFresh ? "translateX(100%)" : "translateX(0)",
- opacity: isNotificationFresh ? "1" : "0",
- },
- true,
- );
- this.notificationBarIframeElement.addEventListener(
- EVENTS.LOAD,
- this.handleNotificationBarIframeOnLoad,
- );
- }
-
- /**
- * Handles the load event for the notification bar iframe.
- * This will animate the notification bar into view.
- */
- private handleNotificationBarIframeOnLoad = () => {
- setElementStyles(
- this.notificationBarIframeElement,
- { transform: "translateX(0)", opacity: "1" },
- true,
- );
-
- this.notificationBarIframeElement?.removeEventListener(
- EVENTS.LOAD,
- this.handleNotificationBarIframeOnLoad,
- );
- };
-
- /**
- * Creates the container for the notification bar iframe with shadow DOM.
- */
- private createNotificationBarElement() {
- if (this.notificationBarIframeElement) {
- this.notificationBarRootElement = globalThis.document.createElement(
- "bit-notification-bar-root",
- );
-
- this.notificationBarShadowRoot = this.notificationBarRootElement.attachShadow({
- mode: "closed",
- delegatesFocus: true,
- });
-
- this.notificationBarElement = globalThis.document.createElement("div");
- this.notificationBarElement.id = "bit-notification-bar";
-
- setElementStyles(this.notificationBarElement, this.notificationBarContainerStyles, true);
-
- this.notificationBarShadowRoot.appendChild(this.notificationBarElement);
- this.notificationBarElement.appendChild(this.notificationBarIframeElement);
- }
- }
-
- /**
- * Sets up the message listener for the initialization of the notification bar.
- * This will send the initialization data to the notification bar iframe.
- *
- * @param initData - The initialization data for the notification bar.
- */
- private setupInitNotificationBarMessageListener(initData: NotificationBarIframeInitData) {
- const handleInitNotificationBarMessage = (event: MessageEvent) => {
- const { source, data } = event;
- if (
- source !== this.notificationBarIframeElement.contentWindow ||
- data?.command !== "initNotificationBar"
- ) {
- return;
- }
-
- this.sendMessageToNotificationBarIframe({
- command: "initNotificationBar",
- initData,
- parentOrigin: globalThis.location.origin,
- });
- globalThis.removeEventListener("message", handleInitNotificationBarMessage);
- };
-
- if (this.notificationBarIframeElement) {
- globalThis.addEventListener("message", handleInitNotificationBarMessage);
- }
- }
-
- /**
- * Closes the notification bar. Will trigger a removal of the notification bar
- * from the background queue if the notification bar was closed by the user.
- *
- * @param closedByUserAction - Whether the notification bar was closed by the user.
- */
- private closeNotificationBar(closedByUserAction: boolean = false) {
- if (!this.notificationBarRootElement && !this.notificationBarIframeElement) {
+ if (message.command !== "saveCipherAttemptCompleted") {
return;
}
- this.notificationBarIframeElement.remove();
- this.notificationBarIframeElement = null;
+ const { error, cipherId, task, itemName } = message.data || {};
+ const validatedTask = this.validateTaskInfo(task);
+ this.renderSaveCipherConfirmation(error, { cipherId, task: validatedTask, itemName });
+ }
- this.notificationBarElement.remove();
- this.notificationBarElement = null;
- this.notificationBarShadowRoot = null;
- this.notificationBarRootElement.remove();
- this.notificationBarRootElement = null;
+ private validateTaskInfo(
+ task: NotificationTaskInfo | undefined,
+ ): NotificationTaskInfo | undefined {
+ if (!task || typeof task !== "object") {
+ return undefined;
+ }
+
+ const orgName = typeof task.orgName === "string" ? task.orgName : undefined;
+ const remainingTasksCount =
+ typeof task.remainingTasksCount === "number" &&
+ Number.isFinite(task.remainingTasksCount) &&
+ task.remainingTasksCount >= 0
+ ? task.remainingTasksCount
+ : undefined;
+
+ if (orgName === undefined || remainingTasksCount === undefined) {
+ return undefined;
+ }
+
+ return { orgName, remainingTasksCount };
+ }
+
+ private openNotificationBar(initData: NotificationBarIframeInitData) {
+ if (
+ !this.notificationBarRootElement ||
+ !globalThis.document.body.contains(this.notificationBarRootElement)
+ ) {
+ if (this.notificationBarRootElement) {
+ this.closeNotificationBar();
+ }
+ this.createNotificationBarElement();
+ globalThis.document.body.appendChild(this.notificationBarRootElement);
+ }
+
+ this.currentNotificationBarType = initData.type as NotificationType;
+ this.prepareNotificationBarEntrance(initData);
+ void this.renderNotificationBarContent(initData);
+ }
+
+ private async renderNotificationBarContent(initData: NotificationBarIframeInitData) {
+ if (!this.notificationBarElement) {
+ return;
+ }
+
+ this.currentNotificationBarInitData = initData;
+ const { i18n, resolvedTheme, notificationType, headerMessage, notificationTestId } =
+ this.getNotificationConfig(initData);
+ const personalVaultDisallowed = Boolean(initData.removeIndividualVault);
+
+ if (initData.isVaultLocked) {
+ this.renderLockedNotification({
+ initData,
+ notificationType,
+ headerMessage,
+ notificationTestId,
+ resolvedTheme,
+ personalVaultDisallowed,
+ i18n,
+ });
+ return;
+ }
+
+ if (initData.type === NotificationTypes.AtRiskPassword) {
+ this.renderContent(
+ AtRiskNotification({
+ ...initData,
+ type: notificationType,
+ theme: resolvedTheme,
+ i18n,
+ notificationTestId,
+ params: initData.params,
+ handleCloseNotification: this.handleCloseNotification,
+ }),
+ );
+ return;
+ }
+
+ const orgId = selectedVaultSignal.get();
+ const [organizations, folders, ciphers, collections] = await Promise.all([
+ sendExtensionMessage("bgGetOrgData"),
+ sendExtensionMessage("bgGetFolderData"),
+ sendExtensionMessage("bgGetDecryptedCiphers"),
+ sendExtensionMessage("bgGetCollectionData", { orgId }),
+ ]);
+
+ this.currentNotificationBarInitData = {
+ ...initData,
+ organizations,
+ folders,
+ ciphers,
+ collections,
+ };
+
+ this.renderContent(
+ NotificationContainer({
+ ...this.currentNotificationBarInitData,
+ headerMessage,
+ type: notificationType,
+ theme: resolvedTheme,
+ notificationTestId,
+ personalVaultIsAllowed: !personalVaultDisallowed,
+ handleCloseNotification: this.handleCloseNotification,
+ handleSaveAction: this.handleSaveAction,
+ handleEditOrUpdateAction: this.handleEditOrUpdateAction,
+ i18n,
+ }),
+ );
+ }
+
+ private renderContent(template: Parameters[0]) {
+ if (!this.notificationBarElement) {
+ return;
+ }
+ render(template, this.notificationBarElement);
+ this.syncEmotionStyles();
+ }
+
+ private getNotificationConfig(initData: NotificationBarIframeInitData) {
+ const i18n = this.getI18n();
+ const resolvedTheme = getResolvedTheme((initData.theme ?? ThemeTypes.Light) as Theme);
+ const notificationType = resolveNotificationType(initData);
+ const headerMessage = getNotificationHeaderMessage(i18n, notificationType);
+ const notificationTestId = getNotificationTestId(notificationType);
+ return { i18n, resolvedTheme, notificationType, headerMessage, notificationTestId };
+ }
+
+ private renderLockedNotification({
+ initData,
+ notificationType,
+ headerMessage,
+ notificationTestId,
+ resolvedTheme,
+ personalVaultDisallowed,
+ i18n,
+ }: {
+ initData: NotificationBarIframeInitData;
+ notificationType: NotificationType;
+ headerMessage?: string;
+ notificationTestId: string;
+ resolvedTheme: Theme;
+ personalVaultDisallowed: boolean;
+ i18n: I18n;
+ }) {
+ if (!this.notificationBarElement) {
+ return;
+ }
+
+ const notificationConfig = {
+ ...initData,
+ headerMessage,
+ type: notificationType,
+ theme: resolvedTheme,
+ notificationTestId,
+ personalVaultIsAllowed: !personalVaultDisallowed,
+ handleCloseNotification: this.handleCloseNotification,
+ handleEditOrUpdateAction: this.handleEditOrUpdateAction,
+ i18n,
+ };
+
+ const handleSaveAction = () => {
+ this.sendSaveCipherMessage(null, true);
+ this.renderContent(
+ NotificationContainer({
+ ...notificationConfig,
+ handleSaveAction: () => {},
+ isLoading: true,
+ }),
+ );
+ };
+
+ this.renderContent(
+ NotificationContainer({
+ ...notificationConfig,
+ handleSaveAction,
+ }),
+ );
+ }
+
+ private renderSaveCipherConfirmation(
+ error?: string,
+ data?: { cipherId?: string; task?: NotificationTaskInfo; itemName?: string },
+ ) {
+ if (!this.notificationBarElement || !this.currentNotificationBarInitData) {
+ return;
+ }
+
+ const { i18n, resolvedTheme } = this.getNotificationConfig(this.currentNotificationBarInitData);
+ const resolvedType = resolveNotificationType(this.currentNotificationBarInitData);
+ const headerMessage = getConfirmationHeaderMessage(i18n, resolvedType, error);
+ const notificationTestId = getNotificationTestId(resolvedType, true);
+
+ globalThis.setTimeout(() => {
+ void sendExtensionMessage("bgCloseNotificationBar");
+ }, 5000);
+
+ this.renderContent(
+ NotificationConfirmationContainer({
+ ...this.currentNotificationBarInitData,
+ error,
+ headerMessage,
+ theme: resolvedTheme,
+ notificationTestId,
+ task: data?.task,
+ itemName: data?.itemName ?? i18n.typeLogin,
+ handleCloseNotification: this.handleCloseNotification,
+ handleOpenTasks: () => {
+ void sendExtensionMessage("bgOpenAtRiskPasswords");
+ },
+ handleOpenVault: (event: Event) => {
+ if (data?.cipherId) {
+ this.openViewVaultItemPopout(data.cipherId);
+ return;
+ }
+ this.openAddEditVaultItemPopout(event);
+ },
+ i18n,
+ type: this.currentNotificationBarInitData.type as NotificationType,
+ }),
+ );
+ }
+
+ private createNotificationBarElement() {
+ this.notificationBarRootElement = globalThis.document.createElement(
+ "bit-notification-bar-root",
+ );
+ this.notificationBarShadowRoot = this.notificationBarRootElement.attachShadow({
+ mode: "closed",
+ delegatesFocus: true,
+ });
+
+ this.notificationBarElement = globalThis.document.createElement("div");
+ this.notificationBarElement.id = "bit-notification-bar";
+ this.expectedContainerStyles = { ...notificationBarContainerStyles };
+ this.updateElementStyles(this.notificationBarElement, this.expectedContainerStyles);
+ this.notificationBarShadowRoot.appendChild(this.notificationBarElement);
+ this.mutationObserver = new MutationObserver(this.handleMutations);
+ this.observeNotificationBar();
+ this.syncEmotionStyles();
+ }
+
+ private syncEmotionStyles() {
+ if (!this.notificationBarShadowRoot) {
+ return;
+ }
+
+ const existingStyle = this.notificationBarShadowRoot.querySelector(
+ "style[data-bit-notification-styles]",
+ );
+ if (existingStyle) {
+ existingStyle.remove();
+ }
+
+ const emotionStyles = globalThis.document.head.querySelectorAll(emotionStyleSelctor);
+ const combinedCSS = Array.from(emotionStyles)
+ .map((styleElement) => styleElement.textContent)
+ .filter((text): text is string => Boolean(text))
+ .join("\n");
+
+ if (combinedCSS) {
+ const combinedStyle = globalThis.document.createElement("style");
+ combinedStyle.setAttribute("data-bit-notification-styles", "true");
+ combinedStyle.textContent = combinedCSS;
+ this.notificationBarShadowRoot.prepend(combinedStyle);
+ }
+ }
+
+ private prepareNotificationBarEntrance(initData: NotificationBarIframeInitData) {
+ const isFresh = Boolean(
+ initData.launchTimestamp && Date.now() - initData.launchTimestamp < 250,
+ );
+
+ if (isFresh) {
+ this.setContainerStyles({ transform: "translateX(100%)", opacity: "1" });
+ requestAnimationFrame(() => this.setContainerStyles({ transform: "translateX(0)" }));
+ return;
+ }
+
+ this.setContainerStyles({ transform: "translateX(0)", opacity: "1" });
+ }
+
+ private setContainerStyles(styles: Partial) {
+ if (!this.notificationBarElement) {
+ return;
+ }
+
+ this.expectedContainerStyles = { ...this.expectedContainerStyles, ...styles };
+ this.updateElementStyles(this.notificationBarElement, this.expectedContainerStyles);
+ }
+
+ private updateElementStyles(element: HTMLElement, styles: Partial) {
+ if (!element) {
+ return;
+ }
+
+ this.unobserveNotificationBar();
+ setElementStyles(element, styles, true);
+ this.observeNotificationBar();
+ }
+
+ private handleMutations = (mutations: MutationRecord[]) => {
+ if (this.isTriggeringExcessiveMutationObserverIterations() || !this.notificationBarElement) {
+ return;
+ }
+
+ for (const mutation of mutations) {
+ if (mutation.type !== "attributes") {
+ continue;
+ }
+
+ const element = mutation.target as HTMLElement;
+ if (mutation.attributeName === "style") {
+ this.notificationBarElement.removeAttribute("style");
+ this.updateElementStyles(this.notificationBarElement, this.expectedContainerStyles);
+ } else {
+ this.handleElementAttributeMutation(element);
+ }
+ }
+ };
+
+ private handleElementAttributeMutation(element: HTMLElement) {
+ if (!this.notificationBarElement || this.foreignMutationsCount >= 10) {
+ if (this.foreignMutationsCount >= 10) {
+ this.forceCloseNotificationBar();
+ }
+ return;
+ }
+
+ for (const attribute of Array.from(element.attributes)) {
+ if (attribute.name === "style") {
+ continue;
+ }
+
+ const expected = this.defaultNotificationBarAttributes[attribute.name];
+ if (!expected) {
+ this.notificationBarElement.removeAttribute(attribute.name);
+ this.foreignMutationsCount++;
+ } else if (attribute.value !== expected) {
+ this.notificationBarElement.setAttribute(attribute.name, expected);
+ this.foreignMutationsCount++;
+ }
+ }
+ }
+
+ private observeNotificationBar() {
+ if (!this.mutationObserver || !this.notificationBarElement) {
+ return;
+ }
+
+ this.mutationObserver.observe(this.notificationBarElement, { attributes: true });
+ }
+
+ private unobserveNotificationBar() {
+ this.mutationObserver?.disconnect();
+ }
+
+ private isTriggeringExcessiveMutationObserverIterations() {
+ if (this.mutationObserverIterationsResetTimeout) {
+ clearTimeout(this.mutationObserverIterationsResetTimeout);
+ }
+
+ this.mutationObserverIterations++;
+ this.mutationObserverIterationsResetTimeout = globalThis.setTimeout(() => {
+ this.mutationObserverIterations = 0;
+ this.foreignMutationsCount = 0;
+ }, 2000);
+
+ if (this.mutationObserverIterations > 20) {
+ clearTimeout(this.mutationObserverIterationsResetTimeout);
+ this.mutationObserverIterations = 0;
+ this.foreignMutationsCount = 0;
+ this.forceCloseNotificationBar();
+ return true;
+ }
+
+ return false;
+ }
+
+ private forceCloseNotificationBar() {
+ this.closeNotificationBar(true);
+ }
+
+ private handleCloseNotification = (event?: Event) => {
+ event?.preventDefault();
+ void sendExtensionMessage("bgCloseNotificationBar", { fadeOutNotification: true });
+ };
+
+ private handleEditOrUpdateAction = (event: Event) => {
+ event.preventDefault();
+ const currentType = this.currentNotificationBarInitData
+ ? resolveNotificationType(this.currentNotificationBarInitData)
+ : NotificationTypes.Change;
+ this.sendSaveCipherMessage(selectedCipherSignal.get(), currentType === NotificationTypes.Add);
+ };
+
+ private handleSaveAction = (event: Event) => {
+ const selectedCipher = selectedCipherSignal.get();
+ const selectedVault = selectedVaultSignal.get();
+ const selectedFolder = selectedFolderSignal.get();
+
+ if (selectedVault.length > 1) {
+ this.openAddEditVaultItemPopout(event, {
+ organizationId: selectedVault,
+ ...(selectedFolder?.length > 1 ? { folder: selectedFolder } : {}),
+ });
+ this.handleCloseNotification(event);
+ return;
+ }
+
+ event.preventDefault();
+ const disallowPersonalVault = Boolean(
+ this.currentNotificationBarInitData?.removeIndividualVault,
+ );
+ this.sendSaveCipherMessage(selectedCipher, disallowPersonalVault, selectedFolder);
+ if (disallowPersonalVault) {
+ return;
+ }
+ };
+
+ private sendSaveCipherMessage(cipherId: string | null, edit: boolean, folder?: string) {
+ void sendExtensionMessage("bgSaveCipher", {
+ cipherId,
+ folder,
+ edit,
+ });
+ }
+
+ private openAddEditVaultItemPopout(
+ event: Event,
+ options: { cipherId?: string; organizationId?: string; folder?: string } = {},
+ ) {
+ event.preventDefault();
+ void sendExtensionMessage("bgOpenAddEditVaultItemPopout", options);
+ }
+
+ private openViewVaultItemPopout(cipherId: string) {
+ void sendExtensionMessage("bgOpenViewVaultItemPopout", { cipherId });
+ }
+
+ private closeNotificationBar(closedByUserAction: boolean = false) {
+ if (!this.notificationBarRootElement) {
+ return;
+ }
const removableNotificationTypes = new Set([
NotificationTypes.Add,
NotificationTypes.Change,
NotificationTypes.AtRiskPassword,
] as NotificationType[]);
+ const shouldNotifyQueue =
+ closedByUserAction && removableNotificationTypes.has(this.currentNotificationBarType);
- if (closedByUserAction && removableNotificationTypes.has(this.currentNotificationBarType)) {
+ this.unobserveNotificationBar();
+ this.mutationObserver = null;
+
+ if (this.mutationObserverIterationsResetTimeout) {
+ clearTimeout(this.mutationObserverIterationsResetTimeout);
+ this.mutationObserverIterationsResetTimeout = null;
+ }
+
+ this.notificationBarElement?.remove();
+ this.notificationBarElement = null;
+ this.notificationBarShadowRoot = null;
+ this.notificationBarRootElement.remove();
+ this.notificationBarRootElement = null;
+ this.currentNotificationBarInitData = null;
+ this.expectedContainerStyles = {};
+
+ if (shouldNotifyQueue) {
void sendExtensionMessage("bgRemoveTabFromNotificationQueue");
}
this.currentNotificationBarType = null;
}
- /**
- * Sends a message to the notification bar iframe.
- *
- * @param message - The message to send to the notification bar iframe.
- */
- private sendMessageToNotificationBarIframe(message: Record) {
- if (this.notificationBarIframeElement) {
- this.notificationBarIframeElement.contentWindow.postMessage(message, this.extensionOrigin);
- }
- }
-
- /**
- * Destroys the notification bar.
- */
destroy() {
this.closeNotificationBar(true);
}
+
+ private getI18n(): I18n {
+ return {
+ atRiskPassword: chrome.i18n.getMessage("atRiskPassword"),
+ changePassword: chrome.i18n.getMessage("changePassword"),
+ close: chrome.i18n.getMessage("close"),
+ collection: chrome.i18n.getMessage("collection"),
+ folder: chrome.i18n.getMessage("folder"),
+ loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"),
+ loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"),
+ myVault: chrome.i18n.getMessage("myVault"),
+ newItem: chrome.i18n.getMessage("newItem"),
+ nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"),
+ notificationLoginSaveConfirmation: chrome.i18n.getMessage(
+ "notificationLoginSaveConfirmation",
+ ),
+ notificationLoginUpdatedConfirmation: chrome.i18n.getMessage(
+ "notificationLoginUpdatedConfirmation",
+ ),
+ notificationNewItemAria: chrome.i18n.getMessage("notificationNewItemAria"),
+ notificationUnlock: chrome.i18n.getMessage("notificationUnlock"),
+ notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"),
+ saveAction: chrome.i18n.getMessage("notificationAddSave"),
+ saveFailure: chrome.i18n.getMessage("saveFailure"),
+ saveFailureDetails: chrome.i18n.getMessage("saveFailureDetails"),
+ saveLogin: chrome.i18n.getMessage("saveLogin"),
+ typeLogin: chrome.i18n.getMessage("typeLogin"),
+ unlockToSave: chrome.i18n.getMessage("unlockToSave"),
+ updateLogin: chrome.i18n.getMessage("updateLogin"),
+ vault: chrome.i18n.getMessage("vault"),
+ view: chrome.i18n.getMessage("view"),
+ };
+ }
}
diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json
index 1651f616e03..1bf3051b830 100644
--- a/apps/browser/src/manifest.json
+++ b/apps/browser/src/manifest.json
@@ -130,7 +130,6 @@
},
"web_accessible_resources": [
"content/fido2-page-script.js",
- "notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png",
"overlay/menu-button.html",
diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json
index 67399192b64..62c1f28a6d3 100644
--- a/apps/browser/src/manifest.v3.json
+++ b/apps/browser/src/manifest.v3.json
@@ -156,7 +156,6 @@
{
"resources": [
"content/fido2-page-script.js",
- "notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png",
"overlay/menu-button.html",
diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js
index 4bc2a90c4ff..c0b24c0dab8 100644
--- a/apps/browser/webpack.base.js
+++ b/apps/browser/webpack.base.js
@@ -145,11 +145,6 @@ module.exports.buildConfig = function buildConfig(params) {
chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"],
browser: browser,
}),
- new HtmlWebpackPlugin({
- template: path.resolve(__dirname, "src/autofill/notification/bar.html"),
- filename: "notification/bar.html",
- chunks: ["notification/bar"],
- }),
new HtmlWebpackPlugin({
template: path.resolve(
__dirname,
@@ -264,7 +259,6 @@ module.exports.buildConfig = function buildConfig(params) {
__dirname,
"src/platform/ipc/content/ipc-content-script.ts",
),
- "notification/bar": path.resolve(__dirname, "src/autofill/notification/bar.ts"),
"overlay/menu-button": path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
diff --git a/bitwarden_license/bit-browser/tsconfig.json b/bitwarden_license/bit-browser/tsconfig.json
index 68c52f9d3c6..071ed28c600 100644
--- a/bitwarden_license/bit-browser/tsconfig.json
+++ b/bitwarden_license/bit-browser/tsconfig.json
@@ -7,7 +7,7 @@
"../../apps/browser/src/autofill/content/*.ts",
"../../apps/browser/src/autofill/fido2/content/*.ts",
- "../../apps/browser/src/autofill/notification/bar.ts",
+ "../../apps/browser/src/autofill/notification/notification-bar-helpers.ts",
"../../apps/browser/src/autofill/overlay/inline-menu/**/*.ts",
"../../apps/browser/src/platform/ipc/content/*.ts",
"../../apps/browser/src/platform/offscreen-document/offscreen-document.ts",