diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index c0af31d25e5..a78f3d3de58 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -22,7 +22,9 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { DialogService } from "@bitwarden/components"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { PopupUtilsService } from "../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; + +import { closeTwoFactorAuthPopout } from "./utils/auth-popout-window"; const BroadcasterSubscriptionId = "TwoFactorComponent"; @@ -31,8 +33,6 @@ const BroadcasterSubscriptionId = "TwoFactorComponent"; templateUrl: "two-factor.component.html", }) export class TwoFactorComponent extends BaseTwoFactorComponent { - showNewWindowMessage = false; - constructor( authService: AuthService, router: Router, @@ -42,7 +42,6 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { private syncService: SyncService, environmentService: EnvironmentService, private broadcasterService: BroadcasterService, - private popupUtilsService: PopupUtilsService, stateService: StateService, route: ActivatedRoute, private messagingService: MessagingService, @@ -115,7 +114,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { if ( this.selectedProviderType === TwoFactorProviderType.Email && - this.popupUtilsService.inPopup(window) + BrowserPopupUtils.inPopup(window) ) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "warning" }, @@ -123,7 +122,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { type: "warning", }); if (confirmed) { - this.popupUtilsService.popOut(window); + BrowserPopupUtils.openCurrentPagePopout(window); } } @@ -142,7 +141,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { // We don't need this window anymore because the intent is for the user to be left // on the web vault screen which tells them to continue in the browser extension (sidebar or popup) - BrowserApi.closeBitwardenExtensionTab(); + await closeTwoFactorAuthPopout(); }; } }); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts new file mode 100644 index 00000000000..1cc0d2948dc --- /dev/null +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts @@ -0,0 +1,103 @@ +import { createChromeTabMock } from "../../../autofill/jest/autofill-mocks"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +import { + AuthPopoutType, + openUnlockPopout, + closeUnlockPopout, + openSsoAuthResultPopout, + openTwoFactorAuthPopout, + closeTwoFactorAuthPopout, +} from "./auth-popout-window"; + +describe("AuthPopoutWindow", () => { + const openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + const sendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + const closeSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "closeSingleActionPopout") + .mockImplementation(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("openUnlockPopout", () => { + it("opens a single action popup that allows the user to unlock the extension and sends a `bgUnlockPopoutOpened` message", async () => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); + const senderTab = { windowId: 1 } as chrome.tabs.Tab; + + await openUnlockPopout(senderTab); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html", { + singleActionKey: AuthPopoutType.unlockExtension, + senderWindowId: 1, + }); + expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened"); + }); + + it("closes any existing popup window types that are open to the unlock extension route", async () => { + const unlockTab = createChromeTabMock({ + url: chrome.runtime.getURL("popup/index.html#/lock"), + }); + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([unlockTab]); + jest.spyOn(BrowserApi, "removeWindow"); + const senderTab = { windowId: 1 } as chrome.tabs.Tab; + + await openUnlockPopout(senderTab); + + expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({ windowType: "popup" }); + expect(BrowserApi.removeWindow).toHaveBeenCalledWith(unlockTab.windowId); + }); + + it("closes any existing popup window types that are open to the login extension route", async () => { + const loginTab = createChromeTabMock({ + url: chrome.runtime.getURL("popup/index.html#/home"), + }); + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]); + jest.spyOn(BrowserApi, "removeWindow"); + const senderTab = { windowId: 1 } as chrome.tabs.Tab; + + await openUnlockPopout(senderTab); + + expect(BrowserApi.removeWindow).toHaveBeenCalledWith(loginTab.windowId); + }); + }); + + describe("closeUnlockPopout", () => { + it("closes the unlock extension popout window", () => { + closeUnlockPopout(); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_unlockExtension"); + }); + }); + + describe("openSsoAuthResultPopout", () => { + it("opens a window that facilitates presentation of the results for SSO authentication", () => { + openSsoAuthResultPopout({ code: "code", state: "state" }); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/sso?code=code&state=state", { + singleActionKey: AuthPopoutType.ssoAuthResult, + }); + }); + }); + + describe("openTwoFactorAuthPopout", () => { + it("opens a window that facilitates two factor authentication", () => { + openTwoFactorAuthPopout({ data: "data", remember: "remember" }); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/2fa;webAuthnResponse=data;remember=remember", + { singleActionKey: AuthPopoutType.twoFactorAuth } + ); + }); + }); + + describe("closeTwoFactorAuthPopout", () => { + it("closes the two-factor authentication window", () => { + closeTwoFactorAuthPopout(); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_twoFactorAuth"); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.ts new file mode 100644 index 00000000000..2f995fc1f4a --- /dev/null +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.ts @@ -0,0 +1,87 @@ +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +const AuthPopoutType = { + unlockExtension: "auth_unlockExtension", + ssoAuthResult: "auth_ssoAuthResult", + twoFactorAuth: "auth_twoFactorAuth", +} as const; +const extensionUnlockUrls = new Set([ + chrome.runtime.getURL("popup/index.html#/lock"), + chrome.runtime.getURL("popup/index.html#/home"), +]); + +/** + * Opens a window that facilitates unlocking / logging into the extension. + * + * @param senderTab - Used to determine the windowId of the sender. + */ +async function openUnlockPopout(senderTab: chrome.tabs.Tab) { + const existingPopoutWindowTabs = await BrowserApi.tabsQuery({ windowType: "popup" }); + existingPopoutWindowTabs.forEach((tab) => { + if (extensionUnlockUrls.has(tab.url)) { + BrowserApi.removeWindow(tab.windowId); + } + }); + + await BrowserPopupUtils.openPopout("popup/index.html", { + singleActionKey: AuthPopoutType.unlockExtension, + senderWindowId: senderTab.windowId, + }); + await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened"); +} + +/** + * Closes the unlock popout window. + */ +async function closeUnlockPopout() { + await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.unlockExtension); +} + +/** + * Opens a window that facilitates presenting the results for SSO authentication. + * + * @param resultData - The result data from the SSO authentication. + */ +async function openSsoAuthResultPopout(resultData: { code: string; state: string }) { + const { code, state } = resultData; + const authResultUrl = `popup/index.html#/sso?code=${encodeURIComponent( + code + )}&state=${encodeURIComponent(state)}`; + + await BrowserPopupUtils.openPopout(authResultUrl, { + singleActionKey: AuthPopoutType.ssoAuthResult, + }); +} + +/** + * Opens a window that facilitates two-factor authentication. + * + * @param twoFactorAuthData - The data from the two-factor authentication. + */ +async function openTwoFactorAuthPopout(twoFactorAuthData: { data: string; remember: string }) { + const { data, remember } = twoFactorAuthData; + const params = + `webAuthnResponse=${encodeURIComponent(data)};` + `remember=${encodeURIComponent(remember)}`; + const twoFactorUrl = `popup/index.html#/2fa;${params}`; + + await BrowserPopupUtils.openPopout(twoFactorUrl, { + singleActionKey: AuthPopoutType.twoFactorAuth, + }); +} + +/** + * Closes the two-factor authentication popout window. + */ +async function closeTwoFactorAuthPopout() { + await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuth); +} + +export { + AuthPopoutType, + openUnlockPopout, + closeUnlockPopout, + openSsoAuthResultPopout, + openTwoFactorAuthPopout, + closeTwoFactorAuthPopout, +}; diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index f95ca60bab1..dada3430af5 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -12,6 +12,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import AddUnlockVaultQueueMessage from "../../background/models/add-unlock-vault-queue-message"; import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage"; import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage"; @@ -21,6 +22,7 @@ import LockedVaultPendingNotificationsItem from "../../background/models/lockedV import { NotificationQueueMessageType } from "../../background/models/notificationQueueMessageType"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; +import { openAddEditVaultItemPopout } from "../../vault/popup/utils/vault-popout-window"; import { AutofillService } from "../services/abstractions/autofill.service"; export default class NotificationBackground { @@ -96,7 +98,7 @@ export default class NotificationBackground { "addToLockedVaultPendingNotifications", retryMessage ); - await BrowserApi.tabSendMessageData(sender.tab, "promptForLogin"); + await openUnlockPopout(sender.tab); return; } await this.saveOrUpdateCredentials(sender.tab, msg.edit, msg.folder); @@ -118,9 +120,12 @@ export default class NotificationBackground { break; } break; - case "promptForLogin": + case "bgUnlockPopoutOpened": await this.unlockVault(sender.tab); break; + case "bgReopenUnlockPopout": + await openUnlockPopout(sender.tab); + break; default: break; } @@ -447,9 +452,7 @@ export default class NotificationBackground { collectionIds: cipherView.collectionIds, }); - await BrowserApi.tabSendMessageData(senderTab, "openAddEditCipher", { - cipherId: cipherView.id, - }); + await openAddEditVaultItemPopout(senderTab, { cipherId: cipherView.id }); } private async folderExists(folderId: string) { diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 54c3e244991..e7f57a46869 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -18,6 +18,7 @@ import { } from "../../auth/background/service-factories/auth-service.factory"; import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory"; import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory"; +import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { Account } from "../../models/account"; @@ -29,6 +30,10 @@ import { cipherServiceFactory, CipherServiceInitOptions, } from "../../vault/background/service_factories/cipher-service.factory"; +import { + openAddEditVaultItemPopout, + openVaultItemPasswordRepromptPopout, +} from "../../vault/popup/utils/vault-popout-window"; import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard"; import { AutofillTabCommand } from "../commands/autofill-tab-command"; @@ -187,7 +192,7 @@ export class ContextMenuClickedHandler { retryMessage ); - await BrowserApi.tabSendMessageData(tab, "promptForLogin"); + await openUnlockPopout(tab); return; } @@ -235,14 +240,12 @@ export class ContextMenuClickedHandler { const cipherType = this.getCipherCreationType(menuItemId); if (cipherType) { - await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { - cipherType, - }); + await openAddEditVaultItemPopout(tab, { cipherType }); break; } if (await this.isPasswordRepromptRequired(cipher)) { - await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, // The action here is passed on to the single-use reprompt window and doesn't change based on cipher type action: AUTOFILL_ID, @@ -255,9 +258,7 @@ export class ContextMenuClickedHandler { } case COPY_USERNAME_ID: if (menuItemId === CREATE_LOGIN_ID) { - await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { - cipherType: CipherType.Login, - }); + await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login }); break; } @@ -265,16 +266,14 @@ export class ContextMenuClickedHandler { break; case COPY_PASSWORD_ID: if (menuItemId === CREATE_LOGIN_ID) { - await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { - cipherType: CipherType.Login, - }); + await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login }); break; } if (await this.isPasswordRepromptRequired(cipher)) { - await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, - action: info.parentMenuItemId, + action: COPY_PASSWORD_ID, }); } else { this.copyToClipboard({ text: cipher.login.password, tab: tab }); @@ -284,16 +283,14 @@ export class ContextMenuClickedHandler { break; case COPY_VERIFICATIONCODE_ID: if (menuItemId === CREATE_LOGIN_ID) { - await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { - cipherType: CipherType.Login, - }); + await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login }); break; } if (await this.isPasswordRepromptRequired(cipher)) { - await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, - action: info.parentMenuItemId, + action: COPY_VERIFICATIONCODE_ID, }); } else { this.copyToClipboard({ diff --git a/apps/browser/src/autofill/content/message_handler.ts b/apps/browser/src/autofill/content/message_handler.ts index 3fdf0f20124..c2c5c4d3196 100644 --- a/apps/browser/src/autofill/content/message_handler.ts +++ b/apps/browser/src/autofill/content/message_handler.ts @@ -28,12 +28,10 @@ window.addEventListener( ); const forwardCommands = [ - "promptForLogin", - "passwordReprompt", + "bgUnlockPopoutOpened", "addToLockedVaultPendingNotifications", "unlockCompleted", "addedCipher", - "openAddEditCipher", ]; chrome.runtime.onMessage.addListener((event) => { diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index dcc4ce010f6..f4110d6dad2 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -189,7 +189,7 @@ function handleTypeUnlock() { const unlockButton = document.getElementById("unlock-vault"); unlockButton.addEventListener("click", (e) => { sendPlatformMessage({ - command: "bgReopenPromptForLogin", + command: "bgReopenUnlockPopout", }); }); } diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 544b0da1a5c..446aae6420d 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -752,13 +752,15 @@ describe("AutofillService", () => { jest .spyOn(userVerificationService, "hasMasterPasswordAndMasterKeyHash") .mockResolvedValueOnce(true); - jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + jest + .spyOn(autofillService as any, "openVaultItemPasswordRepromptPopout") + .mockImplementation(); const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true); expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url); expect(userVerificationService.hasMasterPasswordAndMasterKeyHash).toHaveBeenCalled(); - expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(tab, "passwordReprompt", { + expect(autofillService["openVaultItemPasswordRepromptPopout"]).toHaveBeenCalledWith(tab, { cipherId: cipher.id, action: "autofill", }); @@ -4254,4 +4256,24 @@ describe("AutofillService", () => { expect(result).toBe(false); }); }); + + describe("isDebouncingPasswordRepromptPopout", () => { + it("returns false and sets up the debounce if a master password reprompt window is not currently opening", () => { + jest.spyOn(globalThis, "setTimeout"); + + const result = autofillService["isDebouncingPasswordRepromptPopout"](); + + expect(result).toBe(false); + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 100); + expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(true); + }); + + it("returns true if a master password reprompt window is currently opening", () => { + autofillService["currentlyOpeningPasswordRepromptPopout"] = true; + + const result = autofillService["isDebouncingPasswordRepromptPopout"](); + + expect(result).toBe(true); + }); + }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 8ab20027756..a506425bc39 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -12,6 +12,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; +import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -30,6 +31,10 @@ import { } from "./autofill-constants"; export default class AutofillService implements AutofillServiceInterface { + private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout; + private openPasswordRepromptPopoutDebounce: NodeJS.Timeout; + private currentlyOpeningPasswordRepromptPopout = false; + constructor( private cipherService: CipherService, private stateService: BrowserStateService, @@ -272,13 +277,14 @@ export default class AutofillService implements AutofillServiceInterface { if ( cipher.reprompt === CipherRepromptType.Password && // If the master password has is not available, reprompt will error - (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) + (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) && + !this.isDebouncingPasswordRepromptPopout() ) { if (fromCommand) { this.cipherService.updateLastUsedIndexForUrl(tab.url); } - await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + await this.openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, action: "autofill", }); @@ -1828,4 +1834,22 @@ export default class AutofillService implements AutofillServiceInterface { static forCustomFieldsOnly(field: AutofillField): boolean { return field.tagName === "span"; } + + /** + * Handles debouncing the opening of the master password reprompt popout. + */ + private isDebouncingPasswordRepromptPopout() { + if (this.currentlyOpeningPasswordRepromptPopout) { + return true; + } + + this.currentlyOpeningPasswordRepromptPopout = true; + clearTimeout(this.openPasswordRepromptPopoutDebounce); + + this.openPasswordRepromptPopoutDebounce = setTimeout(() => { + this.currentlyOpeningPasswordRepromptPopout = false; + }, 100); + + return false; + } } diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 77300272d26..210119aa5f2 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -4,6 +4,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { openUnlockPopout } from "../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../platform/browser/browser-api"; import MainBackground from "./main.background"; @@ -87,7 +88,7 @@ export default class CommandsBackground { retryMessage ); - BrowserApi.tabSendMessageData(tab, "promptForLogin"); + await openUnlockPopout(tab); return; } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e82a09ba008..e3fe610fce5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -132,7 +132,6 @@ import { Account } from "../models/account"; import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; -import BrowserPopoutWindowService from "../platform/popup/browser-popout-window.service"; import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; import { BrowserConfigService } from "../platform/services/browser-config.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; @@ -145,7 +144,6 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u import { BrowserStateService } from "../platform/services/browser-state.service"; import { KeyGenerationService } from "../platform/services/key-generation.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; -import { PopupUtilsService } from "../popup/services/popup-utils.service"; import { BrowserSendService } from "../services/browser-send.service"; import { BrowserSettingsService } from "../services/browser-settings.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; @@ -225,8 +223,6 @@ export default class MainBackground { devicesService: DevicesServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction; - popupUtilsService: PopupUtilsService; - browserPopoutWindowService: BrowserPopoutWindowService; accountService: AccountServiceAbstraction; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. @@ -583,12 +579,7 @@ export default class MainBackground { this.messagingService ); - this.browserPopoutWindowService = new BrowserPopoutWindowService(); - - this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService( - this.browserPopoutWindowService, - this.authService - ); + this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); this.fido2AuthenticatorService = new Fido2AuthenticatorService( this.cipherService, this.fido2UserInterfaceService, @@ -634,8 +625,7 @@ export default class MainBackground { this.environmentService, this.messagingService, this.logService, - this.configService, - this.browserPopoutWindowService + this.configService ); this.nativeMessagingBackground = new NativeMessagingBackground( this.cryptoService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 83764da49d5..9d8ab4b898d 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -8,9 +8,13 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { + closeUnlockPopout, + openSsoAuthResultPopout, + openTwoFactorAuthPopout, +} from "../auth/popup/utils/auth-popout-window"; import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../platform/browser/browser-api"; -import { BrowserPopoutWindowService } from "../platform/popup/abstractions/browser-popout-window.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; @@ -37,8 +41,7 @@ export default class RuntimeBackground { private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, private logService: LogService, - private configService: ConfigServiceAbstraction, - private browserPopoutWindowService: BrowserPopoutWindowService + private configService: ConfigServiceAbstraction ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor chrome.runtime.onInstalled.addListener((details: any) => { @@ -82,8 +85,6 @@ export default class RuntimeBackground { } async processMessage(msg: any, sender: chrome.runtime.MessageSender) { - const cipherId = msg.data?.cipherId; - switch (msg.command) { case "loggedIn": case "unlocked": { @@ -91,7 +92,7 @@ export default class RuntimeBackground { if (this.lockedVaultPendingNotifications?.length > 0) { item = this.lockedVaultPendingNotifications.pop(); - await this.browserPopoutWindowService.closeUnlockPrompt(); + await closeUnlockPopout(); } await this.main.refreshBadge(); @@ -129,49 +130,6 @@ export default class RuntimeBackground { case "openPopup": await this.main.openPopup(); break; - case "promptForLogin": - case "bgReopenPromptForLogin": - await this.browserPopoutWindowService.openUnlockPrompt(sender.tab?.windowId); - break; - case "passwordReprompt": - if (cipherId) { - await this.browserPopoutWindowService.openPasswordRepromptPrompt(sender.tab?.windowId, { - cipherId: cipherId, - senderTabId: sender.tab.id, - action: msg.data?.action, - }); - } - break; - case "openAddEditCipher": { - const isNewCipher = !cipherId; - const cipherType = msg.data?.cipherType; - const senderTab = sender.tab; - - if (!senderTab) { - break; - } - - if (isNewCipher) { - await this.browserPopoutWindowService.openCipherCreation(senderTab.windowId, { - cipherType, - senderTabId: senderTab.id, - senderTabURI: senderTab.url, - }); - } else { - await this.browserPopoutWindowService.openCipherEdit(senderTab.windowId, { - cipherId, - senderTabId: senderTab.id, - senderTabURI: senderTab.url, - }); - } - - break; - } - case "closeTab": - setTimeout(() => { - BrowserApi.closeBitwardenExtensionTab(); - }, msg.delay ?? 0); - break; case "triggerAutofillScriptInjection": await this.autofillService.injectAutofillScripts( sender, @@ -266,12 +224,7 @@ export default class RuntimeBackground { }); } else { try { - BrowserApi.createNewTab( - "popup/index.html?uilocation=popout#/sso?code=" + - encodeURIComponent(msg.code) + - "&state=" + - encodeURIComponent(msg.state) - ); + await openSsoAuthResultPopout(msg); } catch { this.logService.error("Unable to open sso popout tab"); } @@ -285,10 +238,7 @@ export default class RuntimeBackground { return; } - const params = - `webAuthnResponse=${encodeURIComponent(msg.data)};` + - `remember=${encodeURIComponent(msg.remember)}`; - BrowserApi.openBitwardenExtensionTab(`popup/index.html#/2fa;${params}`, false); + await openTwoFactorAuthPopout(msg); break; } case "reloadPopup": diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index af9e633a7f1..057ae0a1010 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -9,6 +9,89 @@ describe("BrowserApi", () => { jest.clearAllMocks(); }); + describe("getWindow", () => { + it("will get the current window if a window id is not provided", () => { + BrowserApi.getWindow(); + + expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything()); + }); + + it("will get the window with the provided id if one is provided", () => { + const windowId = 1; + + BrowserApi.getWindow(windowId); + + expect(chrome.windows.get).toHaveBeenCalledWith( + windowId, + { populate: true }, + expect.anything() + ); + }); + }); + + describe("getCurrentWindow", () => { + it("will get the current window", () => { + BrowserApi.getCurrentWindow(); + + expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything()); + }); + }); + + describe("getWindowById", () => { + it("will get the window associated with the passed window id", () => { + const windowId = 1; + + BrowserApi.getWindowById(windowId); + + expect(chrome.windows.get).toHaveBeenCalledWith( + windowId, + { populate: true }, + expect.anything() + ); + }); + }); + + describe("removeWindow", () => { + it("removes the window based on the passed window id", () => { + const windowId = 10; + + BrowserApi.removeWindow(windowId); + + expect(chrome.windows.remove).toHaveBeenCalledWith(windowId, expect.anything()); + }); + }); + + describe("updateWindowProperties", () => { + it("will update the window with the provided window options", () => { + const windowId = 1; + const windowOptions: chrome.windows.UpdateInfo = { + focused: true, + }; + + BrowserApi.updateWindowProperties(windowId, windowOptions); + + expect(chrome.windows.update).toHaveBeenCalledWith( + windowId, + windowOptions, + expect.anything() + ); + }); + }); + + describe("focusWindow", () => { + it("will focus the window with the provided window id", () => { + const windowId = 1; + + BrowserApi.focusWindow(windowId); + + expect(chrome.windows.update).toHaveBeenCalledWith( + windowId, + { focused: true }, + expect.anything() + ); + }); + }); + describe("executeScriptInTab", () => { it("calls to the extension api to execute a script within the give tabId", async () => { const tabId = 1; diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index dce72f5cf65..64efbfdc571 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -19,14 +19,33 @@ export class BrowserApi { return chrome.runtime.getManifest().manifest_version; } - static getWindow(windowId?: number): Promise | void { + /** + * Gets the current window or the window with the given id. + * + * @param windowId - The id of the window to get. If not provided, the current window is returned. + */ + static async getWindow(windowId?: number): Promise { if (!windowId) { - return; + return BrowserApi.getCurrentWindow(); } - return new Promise((resolve) => - chrome.windows.get(windowId, { populate: true }, (window) => resolve(window)) - ); + return await BrowserApi.getWindowById(windowId); + } + + /** + * Gets the currently active browser window. + */ + static async getCurrentWindow(): Promise { + return new Promise((resolve) => chrome.windows.getCurrent({ populate: true }, resolve)); + } + + /** + * Gets the window with the given id. + * + * @param windowId - The id of the window to get. + */ + static async getWindowById(windowId: number): Promise { + return new Promise((resolve) => chrome.windows.get(windowId, { populate: true }, resolve)); } static async createWindow(options: chrome.windows.CreateData): Promise { @@ -37,8 +56,39 @@ export class BrowserApi { ); } - static async removeWindow(windowId: number) { - await chrome.windows.remove(windowId); + /** + * Removes the window with the given id. + * + * @param windowId - The id of the window to remove. + */ + static async removeWindow(windowId: number): Promise { + return new Promise((resolve) => chrome.windows.remove(windowId, () => resolve())); + } + + /** + * Updates the properties of the window with the given id. + * + * @param windowId - The id of the window to update. + * @param options - The window properties to update. + */ + static async updateWindowProperties( + windowId: number, + options: chrome.windows.UpdateInfo + ): Promise { + return new Promise((resolve) => + chrome.windows.update(windowId, options, () => { + resolve(); + }) + ); + } + + /** + * Focuses the window with the given id. + * + * @param windowId - The id of the window to focus. + */ + static async focusWindow(windowId: number) { + await BrowserApi.updateWindowProperties(windowId, { focused: true }); } static async getTabFromCurrentWindowId(): Promise | null { @@ -138,10 +188,6 @@ export class BrowserApi { chrome.tabs.sendMessage(tabId, message, options, responseCallback); } - static async removeTab(tabId: number) { - await chrome.tabs.remove(tabId); - } - static async getPrivateModeWindows(): Promise { return (await browser.windows.getAll()).filter((win) => win.incognito); } @@ -172,47 +218,6 @@ export class BrowserApi { ); } - static async focusWindow(windowId: number) { - await chrome.windows.update(windowId, { focused: true }); - } - - static async openBitwardenExtensionTab(relativeUrl: string, active = true) { - let url = relativeUrl; - if (!relativeUrl.includes("uilocation=tab")) { - const fullUrl = chrome.extension.getURL(relativeUrl); - const parsedUrl = new URL(fullUrl); - parsedUrl.searchParams.set("uilocation", "tab"); - url = parsedUrl.toString(); - } - - const createdTab = await this.createNewTab(url, active); - this.focusWindow(createdTab.windowId); - } - - static async closeBitwardenExtensionTab() { - const tabs = await BrowserApi.tabsQuery({ - active: true, - title: "Bitwarden", - windowType: "normal", - currentWindow: true, - }); - - if (tabs.length === 0) { - return; - } - - const tabToClose = tabs[tabs.length - 1]; - BrowserApi.removeTab(tabToClose.id); - } - - static createNewWindow( - url: string, - focused = true, - type: chrome.windows.createTypeEnum = "normal" - ) { - chrome.windows.create({ url, focused, type }); - } - // Keep track of all the events registered in a Safari popup so we can remove // them when the popup gets unloaded, otherwise we cause a memory leak private static registeredMessageListeners: any[] = []; diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts deleted file mode 100644 index f48649c4f23..00000000000 --- a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; - -interface BrowserPopoutWindowService { - openUnlockPrompt(senderWindowId: number): Promise; - closeUnlockPrompt(): Promise; - openPasswordRepromptPrompt( - senderWindowId: number, - promptData: { - action: string; - cipherId: string; - senderTabId: number; - } - ): Promise; - openCipherCreation( - senderWindowId: number, - promptData: { - cipherType?: CipherType; - senderTabId: number; - senderTabURI: string; - } - ): Promise; - openCipherEdit( - senderWindowId: number, - promptData: { - cipherId: string; - senderTabId: number; - senderTabURI: string; - } - ): Promise; - closePasswordRepromptPrompt(): Promise; - openFido2Popout( - senderWindow: chrome.tabs.Tab, - promptData: { - sessionId: string; - senderTabId: number; - fallbackSupported: boolean; - } - ): Promise; - closeFido2Popout(): Promise; -} - -export { BrowserPopoutWindowService }; diff --git a/apps/browser/src/platform/popup/abstractions/browser-popup-utils.abstractions.ts b/apps/browser/src/platform/popup/abstractions/browser-popup-utils.abstractions.ts new file mode 100644 index 00000000000..c9952342e42 --- /dev/null +++ b/apps/browser/src/platform/popup/abstractions/browser-popup-utils.abstractions.ts @@ -0,0 +1,6 @@ +type ScrollOptions = { + delay: number; + containerSelector: string; +}; + +export { ScrollOptions }; diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts deleted file mode 100644 index c0ebcff6704..00000000000 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; - -import { BrowserApi } from "../browser/browser-api"; - -import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from "./abstractions/browser-popout-window.service"; - -class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { - private singleActionPopoutTabIds: Record = {}; - private defaultPopoutWindowOptions: chrome.windows.CreateData = { - type: "popup", - focused: true, - width: 380, - height: 630, - }; - - async openUnlockPrompt(senderWindowId: number) { - await this.openSingleActionPopout( - senderWindowId, - "popup/index.html?uilocation=popout", - "unlockPrompt" - ); - } - - async closeUnlockPrompt() { - await this.closeSingleActionPopout("unlockPrompt"); - } - - async openPasswordRepromptPrompt( - senderWindowId: number, - { - cipherId, - senderTabId, - action, - }: { - cipherId: string; - senderTabId: number; - action: string; - } - ) { - const promptWindowPath = - "popup/index.html#/view-cipher" + - "?uilocation=popout" + - `&cipherId=${cipherId}` + - `&senderTabId=${senderTabId}` + - `&action=${action}`; - - await this.openSingleActionPopout(senderWindowId, promptWindowPath, "passwordReprompt"); - } - - async openCipherCreation( - senderWindowId: number, - { - cipherType = CipherType.Login, - senderTabId, - senderTabURI, - }: { - cipherType?: CipherType; - senderTabId: number; - senderTabURI: string; - } - ) { - const promptWindowPath = - "popup/index.html#/edit-cipher" + - "?uilocation=popout" + - `&type=${cipherType}` + - `&senderTabId=${senderTabId}` + - `&uri=${senderTabURI}`; - - await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherCreation"); - } - - async openCipherEdit( - senderWindowId: number, - { - cipherId, - senderTabId, - senderTabURI, - }: { - cipherId: string; - senderTabId: number; - senderTabURI: string; - } - ) { - const promptWindowPath = - "popup/index.html#/edit-cipher" + - "?uilocation=popout" + - `&cipherId=${cipherId}` + - `&senderTabId=${senderTabId}` + - `&uri=${senderTabURI}`; - - await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherEdit"); - } - - async closePasswordRepromptPrompt() { - await this.closeSingleActionPopout("passwordReprompt"); - } - - async openFido2Popout( - senderWindow: chrome.tabs.Tab, - { - sessionId, - senderTabId, - fallbackSupported, - }: { - sessionId: string; - senderTabId: number; - fallbackSupported: boolean; - } - ): Promise { - await this.closeFido2Popout(); - - const promptWindowPath = - "popup/index.html#/fido2" + - "?uilocation=popout" + - `&sessionId=${sessionId}` + - `&fallbackSupported=${fallbackSupported}` + - `&senderTabId=${senderTabId}` + - `&senderUrl=${encodeURIComponent(senderWindow.url)}`; - - return await this.openSingleActionPopout( - senderWindow.windowId, - promptWindowPath, - "fido2Popout", - { - height: 450, - } - ); - } - - async closeFido2Popout(): Promise { - await this.closeSingleActionPopout("fido2Popout"); - } - - private async openSingleActionPopout( - senderWindowId: number, - popupWindowURL: string, - singleActionPopoutKey: string, - options: chrome.windows.CreateData = {} - ): Promise { - const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId)); - const url = chrome.extension.getURL(popupWindowURL); - const offsetRight = 15; - const offsetTop = 90; - /// Use overrides in `options` if provided, otherwise use default - const popupWidth = options?.width || this.defaultPopoutWindowOptions.width; - const windowOptions = senderWindow - ? { - ...this.defaultPopoutWindowOptions, - left: senderWindow.left + senderWindow.width - popupWidth - offsetRight, - top: senderWindow.top + offsetTop, - ...options, - url, - } - : { ...this.defaultPopoutWindowOptions, url, ...options }; - - const popupWindow = await BrowserApi.createWindow(windowOptions); - - await this.closeSingleActionPopout(singleActionPopoutKey); - this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id; - - return popupWindow.id; - } - - private async closeSingleActionPopout(popoutKey: string) { - const tabId = this.singleActionPopoutTabIds[popoutKey]; - - if (tabId) { - await BrowserApi.removeTab(tabId); - } - this.singleActionPopoutTabIds[popoutKey] = null; - } -} - -export default BrowserPopoutWindowService; diff --git a/apps/browser/src/platform/popup/browser-popup-utils.spec.ts b/apps/browser/src/platform/popup/browser-popup-utils.spec.ts new file mode 100644 index 00000000000..7a9136ff9eb --- /dev/null +++ b/apps/browser/src/platform/popup/browser-popup-utils.spec.ts @@ -0,0 +1,448 @@ +import { createChromeTabMock } from "../../autofill/jest/autofill-mocks"; +import { BrowserApi } from "../browser/browser-api"; + +import BrowserPopupUtils from "./browser-popup-utils"; + +describe("BrowserPopupUtils", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("inSidebar", () => { + it("should return true if the window contains the sidebar query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window; + + expect(BrowserPopupUtils.inSidebar(win)).toBe(true); + }); + + it("should return false if the window does not contain the sidebar query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=popout" } } as Window; + + expect(BrowserPopupUtils.inSidebar(win)).toBe(false); + }); + }); + + describe("inPopout", () => { + it("should return true if the window contains the popout query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=popout" } } as Window; + + expect(BrowserPopupUtils.inPopout(win)).toBe(true); + }); + + it("should return false if the window does not contain the popout query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window; + + expect(BrowserPopupUtils.inPopout(win)).toBe(false); + }); + }); + + describe("inSingleActionPopout", () => { + it("should return true if the window contains the singleActionPopout query param", () => { + const win = { + location: { href: "https://jest-testing.com?singleActionPopout=123" }, + } as Window; + + expect(BrowserPopupUtils.inSingleActionPopout(win, "123")).toBe(true); + }); + + it("should return false if the window does not contain the singleActionPopout query param", () => { + const win = { location: { href: "https://jest-testing.com" } } as Window; + + expect(BrowserPopupUtils.inSingleActionPopout(win, "123")).toBe(false); + }); + }); + + describe("inPopup", () => { + it("should return true if the window does not contain the popup query param", () => { + const win = { location: { href: "https://jest-testing.com" } } as Window; + + expect(BrowserPopupUtils.inPopup(win)).toBe(true); + }); + + it("should return true if the window contains the popup query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=popup" } } as Window; + + expect(BrowserPopupUtils.inPopup(win)).toBe(true); + }); + + it("should return false if the window does not contain the popup query param", () => { + const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window; + + expect(BrowserPopupUtils.inPopup(win)).toBe(false); + }); + }); + + describe("getContentScrollY", () => { + it("should return the scroll position of the popup", () => { + const win = { + document: { getElementsByTagName: () => [{ scrollTop: 100 }] }, + } as unknown as Window; + + expect(BrowserPopupUtils.getContentScrollY(win)).toBe(100); + }); + }); + + describe("setContentScrollY", () => { + it("should set the scroll position of the popup", async () => { + window.document.body.innerHTML = ` +
+
+
+ `; + + await BrowserPopupUtils.setContentScrollY(window, 200); + + expect(window.document.getElementsByTagName("main")[0].scrollTop).toBe(200); + }); + + it("should not set the scroll position of the popup if the scrollY is null", async () => { + window.document.body.innerHTML = ` +
+
+
+ `; + + await BrowserPopupUtils.setContentScrollY(window, null); + + expect(window.document.getElementsByTagName("main")[0].scrollTop).toBe(0); + }); + + it("will set the scroll position of the popup after the provided delay", async () => { + jest.useRealTimers(); + window.document.body.innerHTML = ` +
+
+
+ `; + + await BrowserPopupUtils.setContentScrollY(window, 300, { + delay: 200, + containerSelector: ".scrolling-container", + }); + + expect(window.document.querySelector(".scrolling-container").scrollTop).toBe(300); + }); + }); + + describe("backgroundInitializationRequired", () => { + it("return true if the background page is a null value", () => { + jest.spyOn(BrowserApi, "getBackgroundPage").mockReturnValue(null); + + expect(BrowserPopupUtils.backgroundInitializationRequired()).toBe(true); + }); + + it("return false if the background page is not a null value", () => { + jest.spyOn(BrowserApi, "getBackgroundPage").mockReturnValue({}); + + expect(BrowserPopupUtils.backgroundInitializationRequired()).toBe(false); + }); + }); + + describe("inPrivateMode", () => { + it("returns false if the background requires initialization", () => { + jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(false); + + expect(BrowserPopupUtils.inPrivateMode()).toBe(false); + }); + + it("returns false if the manifest version is for version 3", () => { + jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + + expect(BrowserPopupUtils.inPrivateMode()).toBe(false); + }); + + it("returns true if the background does not require initalization and the manifest version is version 2", () => { + jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); + + expect(BrowserPopupUtils.inPrivateMode()).toBe(true); + }); + }); + + describe("openPopout", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ + id: 1, + left: 100, + top: 100, + focused: false, + alwaysOnTop: false, + incognito: false, + width: 380, + }); + jest.spyOn(BrowserApi, "createWindow").mockImplementation(); + }); + + it("creates a window with the default window options", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + + await BrowserPopupUtils.openPopout(url); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: true, + width: 380, + height: 630, + left: 85, + top: 190, + url: `chrome-extension://id/${url}?uilocation=popout`, + }); + }); + + it("replaces any existing `uilocation=` query params within the passed extension url path to state the the uilocaiton is a popup", async () => { + const url = "popup/index.html#/tabs/vault?uilocation=sidebar"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + + await BrowserPopupUtils.openPopout(url); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: true, + width: 380, + height: 630, + left: 85, + top: 190, + url: `chrome-extension://id/popup/index.html#/tabs/vault?uilocation=popout`, + }); + }); + + it("appends the uilocation to the search params if an existing param is passed with the extension url path", async () => { + const url = "popup/index.html#/tabs/vault?existingParam=123"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + + await BrowserPopupUtils.openPopout(url); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: true, + width: 380, + height: 630, + left: 85, + top: 190, + url: `chrome-extension://id/${url}&uilocation=popout`, + }); + }); + + it("creates a single action popout window", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + + await BrowserPopupUtils.openPopout(url, { singleActionKey: "123" }); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: true, + width: 380, + height: 630, + left: 85, + top: 190, + url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, + }); + }); + + it("does not create a single action popout window if it is already open", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(true); + + await BrowserPopupUtils.openPopout(url, { singleActionKey: "123" }); + + expect(BrowserApi.createWindow).not.toHaveBeenCalled(); + }); + + it("creates a window with the provided window options", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + + await BrowserPopupUtils.openPopout(url, { + windowOptions: { + type: "popup", + focused: false, + width: 100, + height: 100, + }, + }); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: false, + width: 100, + height: 100, + left: 85, + top: 190, + url: `chrome-extension://id/${url}?uilocation=popout`, + }); + }); + + it("opens a single action window if the forceCloseExistingWindows param is true", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(true); + + await BrowserPopupUtils.openPopout(url, { + singleActionKey: "123", + forceCloseExistingWindows: true, + }); + + expect(BrowserApi.createWindow).toHaveBeenCalledWith({ + type: "popup", + focused: true, + width: 380, + height: 630, + left: 85, + top: 190, + url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, + }); + }); + }); + + describe("openCurrentPagePopout", () => { + it("opens a popout window for the current page", async () => { + const win = { location: { href: "https://example.com#/tabs/current" } } as Window; + jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false); + + await BrowserPopupUtils.openCurrentPagePopout(win); + + expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/tabs/vault"); + expect(BrowserApi.closePopup).not.toHaveBeenCalled(); + }); + + it("opens a popout window for the specified URL", async () => { + const win = {} as Window; + jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false); + + await BrowserPopupUtils.openCurrentPagePopout(win, "https://example.com#/settings"); + + expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/settings"); + }); + + it("opens a popout window for the current page and closes the popup window", async () => { + const win = { location: { href: "https://example.com/#/tabs/vault" } } as Window; + jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true); + + await BrowserPopupUtils.openCurrentPagePopout(win); + + expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/tabs/vault"); + expect(BrowserApi.closePopup).toHaveBeenCalledWith(win); + }); + }); + + describe("closeSingleActionPopout", () => { + it("closes any existing single action popouts", async () => { + const url = "popup/index.html"; + jest.useFakeTimers(); + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([ + createChromeTabMock({ + id: 10, + url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, + windowId: 11, + }), + createChromeTabMock({ + id: 20, + url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, + windowId: 21, + }), + createChromeTabMock({ + id: 30, + url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=456`, + windowId: 31, + }), + ]); + jest.spyOn(BrowserApi, "removeWindow").mockResolvedValueOnce(); + + await BrowserPopupUtils.closeSingleActionPopout("123"); + jest.runOnlyPendingTimers(); + + expect(BrowserApi.removeWindow).toHaveBeenNthCalledWith(1, 11); + expect(BrowserApi.removeWindow).toHaveBeenNthCalledWith(2, 21); + expect(BrowserApi.removeWindow).not.toHaveBeenCalledWith(31); + }); + }); + + describe("isSingleActionPopoutOpen", () => { + const windowOptions = { + id: 1, + left: 100, + top: 100, + focused: false, + alwaysOnTop: false, + incognito: false, + width: 500, + height: 800, + }; + + beforeEach(() => { + jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); + jest.spyOn(BrowserApi, "removeWindow").mockImplementation(); + }); + + it("returns false if the popoutKey is not provided", async () => { + await expect(BrowserPopupUtils["isSingleActionPopoutOpen"](undefined, {})).resolves.toBe( + false + ); + }); + + it("returns false if no popout windows are found", async () => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([]); + + await expect( + BrowserPopupUtils["isSingleActionPopoutOpen"]("123", windowOptions) + ).resolves.toBe(false); + }); + + it("returns false if no single action popout is found relating to the popoutKey", async () => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([ + createChromeTabMock({ + id: 10, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`, + }), + createChromeTabMock({ + id: 20, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`, + }), + createChromeTabMock({ + id: 30, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=456`, + }), + ]); + + await expect( + BrowserPopupUtils["isSingleActionPopoutOpen"]("789", windowOptions) + ).resolves.toBe(false); + }); + + it("returns true if a single action popout is found relating to the popoutKey", async () => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([ + createChromeTabMock({ + id: 10, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`, + }), + createChromeTabMock({ + id: 20, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`, + }), + createChromeTabMock({ + id: 30, + url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=456`, + }), + ]); + + await expect( + BrowserPopupUtils["isSingleActionPopoutOpen"]("123", windowOptions) + ).resolves.toBe(true); + expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, { + focused: true, + width: 500, + height: 800, + top: 100, + left: 100, + }); + expect(BrowserApi.removeWindow).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/browser/src/platform/popup/browser-popup-utils.ts b/apps/browser/src/platform/popup/browser-popup-utils.ts new file mode 100644 index 00000000000..3e687fa0815 --- /dev/null +++ b/apps/browser/src/platform/popup/browser-popup-utils.ts @@ -0,0 +1,275 @@ +import { BrowserApi } from "../browser/browser-api"; + +import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions"; + +class BrowserPopupUtils { + /** + * Identifies if the popup is within the sidebar. + * + * @param win - The passed window object. + */ + static inSidebar(win: Window): boolean { + return BrowserPopupUtils.urlContainsSearchParams(win, "uilocation", "sidebar"); + } + + /** + * Identifies if the popup is within the popout. + * + * @param win - The passed window object. + */ + static inPopout(win: Window): boolean { + return BrowserPopupUtils.urlContainsSearchParams(win, "uilocation", "popout"); + } + + /** + * Identifies if the popup is within the single action popout. + * + * @param win - The passed window object. + * @param popoutKey - The single action popout key used to identify the popout. + */ + static inSingleActionPopout(win: Window, popoutKey: string): boolean { + return BrowserPopupUtils.urlContainsSearchParams(win, "singleActionPopout", popoutKey); + } + + /** + * Identifies if the popup is within the popup. + * + * @param win - The passed window object. + */ + static inPopup(win: Window): boolean { + return ( + win.location.href.indexOf("uilocation=") === -1 || + win.location.href.indexOf("uilocation=popup") > -1 + ); + } + + /** + * Gets the scroll position of the popup. + * + * @param win - The passed window object. + * @param scrollingContainer - Element tag name of the scrolling container. + */ + static getContentScrollY(win: Window, scrollingContainer = "main"): number { + const content = win.document.getElementsByTagName(scrollingContainer)[0]; + return content.scrollTop; + } + + /** + * Sets the scroll position of the popup. + * + * @param win - The passed window object. + * @param scrollYAmount - The amount to scroll the popup. + * @param options - Allows for setting the delay in ms to wait before scrolling the popup and the scrolling container tag name. + */ + static async setContentScrollY( + win: Window, + scrollYAmount: number | undefined, + options: ScrollOptions = { + delay: 0, + containerSelector: "main", + } + ) { + const { delay, containerSelector } = options; + return new Promise((resolve) => + win.setTimeout(() => { + const container = win.document.querySelector(containerSelector); + if (!isNaN(scrollYAmount) && container) { + container.scrollTop = scrollYAmount; + } + + resolve(); + }, delay) + ); + } + + /** + * Identifies if the background page needs to be initialized. + */ + static backgroundInitializationRequired() { + return BrowserApi.getBackgroundPage() === null; + } + + /** + * Identifies if the popup is loading in private mode. + */ + static inPrivateMode() { + return BrowserPopupUtils.backgroundInitializationRequired() && BrowserApi.manifestVersion !== 3; + } + + /** + * Opens a popout window of any extension page. If the popout window is already open, it will be focused. + * + * @param extensionUrlPath - A relative path to the extension page. Example: "popup/index.html#/tabs/vault" + * @param options - Options for the popout window that overrides the default options. + */ + static async openPopout( + extensionUrlPath: string, + options: { + senderWindowId?: number; + singleActionKey?: string; + forceCloseExistingWindows?: boolean; + windowOptions?: Partial; + } = {} + ) { + const { senderWindowId, singleActionKey, forceCloseExistingWindows, windowOptions } = options; + const defaultPopoutWindowOptions: chrome.windows.CreateData = { + type: "popup", + focused: true, + width: 380, + height: 630, + }; + const offsetRight = 15; + const offsetTop = 90; + const popupWidth = defaultPopoutWindowOptions.width; + const senderWindow = await BrowserApi.getWindow(senderWindowId); + const popoutWindowOptions = { + left: senderWindow.left + senderWindow.width - popupWidth - offsetRight, + top: senderWindow.top + offsetTop, + ...defaultPopoutWindowOptions, + ...windowOptions, + url: chrome.runtime.getURL( + BrowserPopupUtils.buildPopoutUrlPath(extensionUrlPath, singleActionKey) + ), + }; + + if ( + (await BrowserPopupUtils.isSingleActionPopoutOpen( + singleActionKey, + popoutWindowOptions, + forceCloseExistingWindows + )) && + !forceCloseExistingWindows + ) { + return; + } + + return await BrowserApi.createWindow(popoutWindowOptions); + } + + /** + * Closes the single action popout window. + * + * @param popoutKey - The single action popout key used to identify the popout. + * @param delayClose - The amount of time to wait before closing the popout. Defaults to 0. + */ + static async closeSingleActionPopout(popoutKey: string, delayClose = 0): Promise { + const extensionUrl = chrome.runtime.getURL("popup/index.html"); + const tabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` }); + for (const tab of tabs) { + if (!tab.url.includes(`singleActionPopout=${popoutKey}`)) { + continue; + } + + setTimeout(() => BrowserApi.removeWindow(tab.windowId), delayClose); + } + } + + /** + * Opens a popout window for the current page. + * If the current page is set for the current tab, then the + * popout window will be set for the vault items listing tab. + * + * @param win - The passed window object. + * @param href - The href to open in the popout window. + */ + static async openCurrentPagePopout(win: Window, href: string = null) { + const popoutUrl = href || win.location.href; + const parsedUrl = new URL(popoutUrl); + let hashRoute = parsedUrl.hash; + if (hashRoute.startsWith("#/tabs/current")) { + hashRoute = "#/tabs/vault"; + } + + await BrowserPopupUtils.openPopout(`${parsedUrl.pathname}${hashRoute}`); + + if (BrowserPopupUtils.inPopup(win)) { + BrowserApi.closePopup(win); + } + } + + /** + * Identifies if a single action window is open based on the passed popoutKey. + * Will focus the existing window, and close any other windows that might exist + * with the same popout key. + * + * @param popoutKey - The single action popout key used to identify the popout. + * @param windowInfo - The window info to use to update the existing window. + * @param forceCloseExistingWindows - Identifies if the existing windows should be closed. + */ + private static async isSingleActionPopoutOpen( + popoutKey: string | undefined, + windowInfo: chrome.windows.CreateData, + forceCloseExistingWindows = false + ) { + if (!popoutKey) { + return false; + } + + const extensionUrl = chrome.runtime.getURL("popup/index.html"); + const popoutTabs = (await BrowserApi.tabsQuery({ url: `${extensionUrl}*` })).filter((tab) => + tab.url.includes(`singleActionPopout=${popoutKey}`) + ); + if (popoutTabs.length === 0) { + return false; + } + + if (!forceCloseExistingWindows) { + // Update first, remove it from list + const tab = popoutTabs.shift(); + await BrowserApi.updateWindowProperties(tab.windowId, { + focused: true, + width: windowInfo.width, + height: windowInfo.height, + top: windowInfo.top, + left: windowInfo.left, + }); + } + + popoutTabs.forEach((tab) => BrowserApi.removeWindow(tab.windowId)); + + return true; + } + + /** + * Identifies if the url contains the specified search param and value. + * + * @param win - The passed window object. + * @param searchParam - The search param to identify. + * @param searchValue - The search value to identify. + */ + private static urlContainsSearchParams( + win: Window, + searchParam: string, + searchValue: string + ): boolean { + return win.location.href.indexOf(`${searchParam}=${searchValue}`) > -1; + } + + /** + * Builds the popout url path. Ensures that the uilocation param is set to + * `popout` and that the singleActionPopout param is set to the passed singleActionKey. + * + * @param extensionUrlPath - A relative path to the extension page. Example: "popup/index.html#/tabs/vault" + * @param singleActionKey - The single action popout key used to identify the popout. + */ + private static buildPopoutUrlPath(extensionUrlPath: string, singleActionKey: string) { + let formattedExtensionUrlPath = extensionUrlPath; + if (formattedExtensionUrlPath.includes("uilocation=")) { + formattedExtensionUrlPath = formattedExtensionUrlPath.replace( + /uilocation=[^&]*/g, + "uilocation=popout" + ); + } else { + formattedExtensionUrlPath += + (formattedExtensionUrlPath.includes("?") ? "&" : "?") + "uilocation=popout"; + } + + if (singleActionKey) { + formattedExtensionUrlPath += `&singleActionPopout=${singleActionKey}`; + } + + return formattedExtensionUrlPath; + } +} + +export default BrowserPopupUtils; diff --git a/apps/browser/src/popup/components/pop-out.component.ts b/apps/browser/src/popup/components/pop-out.component.ts index b8675ec4d4c..88587574359 100644 --- a/apps/browser/src/popup/components/pop-out.component.ts +++ b/apps/browser/src/popup/components/pop-out.component.ts @@ -2,7 +2,7 @@ import { Component, Input, OnInit } from "@angular/core"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { PopupUtilsService } from "../services/popup-utils.service"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; @Component({ selector: "app-pop-out", @@ -11,16 +11,13 @@ import { PopupUtilsService } from "../services/popup-utils.service"; export class PopOutComponent implements OnInit { @Input() show = true; - constructor( - private platformUtilsService: PlatformUtilsService, - private popupUtilsService: PopupUtilsService - ) {} + constructor(private platformUtilsService: PlatformUtilsService) {} ngOnInit() { if (this.show) { if ( - (this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) || - this.popupUtilsService.inPopout(window) + (BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()) || + BrowserPopupUtils.inPopout(window) ) { this.show = false; } @@ -28,6 +25,6 @@ export class PopOutComponent implements OnInit { } expand() { - this.popupUtilsService.popOut(window); + BrowserPopupUtils.openCurrentPagePopout(window); } } diff --git a/apps/browser/src/popup/components/private-mode-warning.component.ts b/apps/browser/src/popup/components/private-mode-warning.component.ts index 4d5587d65f1..ff6292bdbf6 100644 --- a/apps/browser/src/popup/components/private-mode-warning.component.ts +++ b/apps/browser/src/popup/components/private-mode-warning.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from "@angular/core"; -import { PopupUtilsService } from "../services/popup-utils.service"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; @Component({ selector: "app-private-mode-warning", @@ -9,9 +9,7 @@ import { PopupUtilsService } from "../services/popup-utils.service"; export class PrivateModeWarningComponent implements OnInit { showWarning = false; - constructor(private popupUtilsService: PopupUtilsService) {} - ngOnInit() { - this.showWarning = this.popupUtilsService.inPrivateMode(); + this.showWarning = BrowserPopupUtils.inPrivateMode(); } } diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index c9a1ffb720e..5dd76ebe183 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -6,16 +6,14 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "./popup-utils.service"; - @Injectable() export class InitService { constructor( private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private popupUtilsService: PopupUtilsService, private stateService: StateServiceAbstraction, private logService: LogServiceAbstraction, private themingService: AbstractThemingService, @@ -26,7 +24,7 @@ export class InitService { return async () => { await this.stateService.init(); - if (!this.popupUtilsService.inPopup(window)) { + if (!BrowserPopupUtils.inPopup(window)) { window.document.body.classList.add("body-full"); } else if (window.screen.availHeight < 600) { window.document.body.classList.add("body-xs"); @@ -43,7 +41,7 @@ export class InitService { if ( this.platformUtilsService.isChrome() && navigator.platform.indexOf("Mac") > -1 && - this.popupUtilsService.inPopup(window) && + BrowserPopupUtils.inPopup(window) && (window.screenLeft < 0 || window.screenTop < 0 || window.screenLeft > window.screen.width || diff --git a/apps/browser/src/popup/services/popup-close-warning.service.ts b/apps/browser/src/popup/services/popup-close-warning.service.ts new file mode 100644 index 00000000000..536def47d5d --- /dev/null +++ b/apps/browser/src/popup/services/popup-close-warning.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from "@angular/core"; +import { fromEvent, Subscription } from "rxjs"; + +@Injectable() +export class PopupCloseWarningService { + private unloadSubscription: Subscription; + + /** + * Enables a pop-up warning before the user exits the window/tab, or navigates away. + * This warns the user that they may lose unsaved data if they leave the page. + * (Note: navigating within the Angular app will not trigger it because it's an SPA.) + * Make sure you call `PopupCloseWarningService.disable` when it is no longer relevant. + */ + enable() { + this.disable(); + + this.unloadSubscription = fromEvent(window, "beforeunload").subscribe( + (e: BeforeUnloadEvent) => { + // Recommended method but not widely supported + e.preventDefault(); + + // Modern browsers do not display this message, it just needs to be a non-nullish value + // Exact wording is determined by the browser + const confirmationMessage = ""; + + // Older methods with better support + e.returnValue = confirmationMessage; + return confirmationMessage; + } + ); + } + + /** + * Disables the warning enabled by PopupCloseWarningService.enable. + */ + disable() { + this.unloadSubscription?.unsubscribe(); + } +} diff --git a/apps/browser/src/popup/services/popup-utils.service.ts b/apps/browser/src/popup/services/popup-utils.service.ts index b5a5a058171..e69de29bb2d 100644 --- a/apps/browser/src/popup/services/popup-utils.service.ts +++ b/apps/browser/src/popup/services/popup-utils.service.ts @@ -1,151 +0,0 @@ -import { Injectable } from "@angular/core"; -import { fromEvent, Subscription } from "rxjs"; - -import { BrowserApi } from "../../platform/browser/browser-api"; - -export type Popout = - | { - type: "window"; - window: chrome.windows.Window; - } - | { - type: "tab"; - tab: chrome.tabs.Tab; - }; - -@Injectable() -export class PopupUtilsService { - private unloadSubscription: Subscription; - - constructor(private privateMode: boolean = false) {} - - inSidebar(win: Window): boolean { - return win.location.search !== "" && win.location.search.indexOf("uilocation=sidebar") > -1; - } - - inTab(win: Window): boolean { - return win.location.search !== "" && win.location.search.indexOf("uilocation=tab") > -1; - } - - inPopout(win: Window): boolean { - return win.location.search !== "" && win.location.search.indexOf("uilocation=popout") > -1; - } - - inPopup(win: Window): boolean { - return ( - win.location.search === "" || - win.location.search.indexOf("uilocation=") === -1 || - win.location.search.indexOf("uilocation=popup") > -1 - ); - } - - inPrivateMode(): boolean { - return this.privateMode; - } - - getContentScrollY(win: Window, scrollingContainer = "main"): number { - const content = win.document.getElementsByTagName(scrollingContainer)[0]; - return content.scrollTop; - } - - setContentScrollY(win: Window, scrollY: number, scrollingContainer = "main"): void { - if (scrollY != null) { - const content = win.document.getElementsByTagName(scrollingContainer)[0]; - content.scrollTop = scrollY; - } - } - - async popOut( - win: Window, - href: string = null, - options: { center?: boolean } = {} - ): Promise { - if (href === null) { - href = win.location.href; - } - - if (typeof chrome !== "undefined" && chrome?.windows?.create != null) { - if (href.indexOf("?uilocation=") > -1) { - href = href - .replace("uilocation=popup", "uilocation=popout") - .replace("uilocation=tab", "uilocation=popout") - .replace("uilocation=sidebar", "uilocation=popout"); - } else { - const hrefParts = href.split("#"); - href = - hrefParts[0] + "?uilocation=popout" + (hrefParts.length > 0 ? "#" + hrefParts[1] : ""); - } - - const bodyRect = document.querySelector("body").getBoundingClientRect(); - const width = Math.round(bodyRect.width ? bodyRect.width + 60 : 375); - const height = Math.round(bodyRect.height || 600); - const top = options.center ? Math.round((screen.height - height) / 2) : undefined; - const left = options.center ? Math.round((screen.width - width) / 2) : undefined; - const window = await BrowserApi.createWindow({ - url: href, - type: "popup", - width, - height, - top, - left, - }); - - if (win && this.inPopup(win)) { - BrowserApi.closePopup(win); - } - - return { type: "window", window }; - } else if (chrome?.tabs?.create != null) { - href = href - .replace("uilocation=popup", "uilocation=tab") - .replace("uilocation=popout", "uilocation=tab") - .replace("uilocation=sidebar", "uilocation=tab"); - - const tab = await BrowserApi.createNewTab(href); - return { type: "tab", tab }; - } else { - throw new Error("Cannot open tab or window"); - } - } - - closePopOut(popout: Popout): Promise { - switch (popout.type) { - case "window": - return BrowserApi.removeWindow(popout.window.id); - case "tab": - return BrowserApi.removeTab(popout.tab.id); - } - } - - /** - * Enables a pop-up warning before the user exits the window/tab, or navigates away. - * This warns the user that they may lose unsaved data if they leave the page. - * (Note: navigating within the Angular app will not trigger it because it's an SPA.) - * Make sure you call `disableTabCloseWarning` when it is no longer relevant. - */ - enableCloseTabWarning() { - this.disableCloseTabWarning(); - - this.unloadSubscription = fromEvent(window, "beforeunload").subscribe( - (e: BeforeUnloadEvent) => { - // Recommended method but not widely supported - e.preventDefault(); - - // Modern browsers do not display this message, it just needs to be a non-nullish value - // Exact wording is determined by the browser - const confirmationMessage = ""; - - // Older methods with better support - e.returnValue = confirmationMessage; - return confirmationMessage; - } - ); - } - - /** - * Disables the warning enabled by enableCloseTabWarning. - */ - disableCloseTabWarning() { - this.unloadSubscription?.unsubscribe(); - } -} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 2bdeaffc3d9..2edc90286b0 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -94,6 +94,7 @@ import { AutofillService } from "../../autofill/services/abstractions/autofill.s import MainBackground from "../../background/main.background"; import { Account } from "../../models/account"; import { BrowserApi } from "../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; import { BrowserConfigService } from "../../platform/services/browser-config.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; @@ -110,11 +111,11 @@ import { VaultFilterService } from "../../vault/services/vault-filter.service"; import { DebounceNavigationService } from "./debounceNavigationService"; import { InitService } from "./init.service"; +import { PopupCloseWarningService } from "./popup-close-warning.service"; import { PopupSearchService } from "./popup-search.service"; -import { PopupUtilsService } from "./popup-utils.service"; -const needsBackgroundInit = BrowserApi.getBackgroundPage() == null; -const isPrivateMode = needsBackgroundInit && BrowserApi.manifestVersion !== 3; +const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired(); +const isPrivateMode = BrowserPopupUtils.inPrivateMode(); const mainBackground: MainBackground = needsBackgroundInit ? createLocalBgService() : BrowserApi.getBackgroundPage().bitwardenMain; @@ -138,6 +139,7 @@ function getBgService(service: keyof MainBackground) { InitService, DebounceNavigationService, DialogService, + PopupCloseWarningService, { provide: LOCALE_ID, useFactory: () => getBgService("i18nService")().translationLocale, @@ -150,7 +152,6 @@ function getBgService(service: keyof MainBackground) { multi: true, }, { provide: BaseUnauthGuardService, useClass: UnauthGuardService }, - { provide: PopupUtilsService, useFactory: () => new PopupUtilsService(isPrivateMode) }, { provide: MessagingService, useFactory: () => { @@ -523,13 +524,10 @@ function getBgService(service: keyof MainBackground) { }, { provide: FilePopoutUtilsService, - useFactory: ( - platformUtilsService: PlatformUtilsService, - popupUtilsService: PopupUtilsService - ) => { - return new FilePopoutUtilsService(platformUtilsService, popupUtilsService); + useFactory: (platformUtilsService: PlatformUtilsService) => { + return new FilePopoutUtilsService(platformUtilsService); }, - deps: [PlatformUtilsService, PopupUtilsService], + deps: [PlatformUtilsService], }, ], }) diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts index 439402437e6..b67564a07b5 100644 --- a/apps/browser/src/popup/settings/settings.component.ts +++ b/apps/browser/src/popup/settings/settings.component.ts @@ -36,7 +36,7 @@ import { DialogService } from "@bitwarden/components"; import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { PopupUtilsService } from "../services/popup-utils.service"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import { AboutComponent } from "./about.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; @@ -95,7 +95,6 @@ export class SettingsComponent implements OnInit { private environmentService: EnvironmentService, private cryptoService: CryptoService, private stateService: StateService, - private popupUtilsService: PopupUtilsService, private modalService: ModalService, private userVerificationService: UserVerificationService, private dialogService: DialogService, @@ -335,7 +334,7 @@ export class SettingsComponent implements OnInit { // eslint-disable-next-line console.error(e); - if (this.platformUtilsService.isFirefox() && this.popupUtilsService.inSidebar(window)) { + if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) { await this.dialogService.openSimpleDialog({ title: { key: "nativeMessaginPermissionSidebarTitle" }, content: { key: "nativeMessaginPermissionSidebarDesc" }, @@ -474,7 +473,7 @@ export class SettingsComponent implements OnInit { async import() { await this.router.navigate(["/import"]); if (await BrowserApi.isPopupOpen()) { - this.popupUtilsService.popOut(window); + BrowserPopupUtils.openCurrentPagePopout(window); } } diff --git a/apps/browser/src/popup/tabs.component.ts b/apps/browser/src/popup/tabs.component.ts index 856529a0a3b..7546c9ca13b 100644 --- a/apps/browser/src/popup/tabs.component.ts +++ b/apps/browser/src/popup/tabs.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from "@angular/core"; -import { PopupUtilsService } from "./services/popup-utils.service"; +import BrowserPopupUtils from "../platform/popup/browser-popup-utils"; @Component({ selector: "app-tabs", @@ -9,9 +9,7 @@ import { PopupUtilsService } from "./services/popup-utils.service"; export class TabsComponent implements OnInit { showCurrentTab = true; - constructor(private popupUtilsService: PopupUtilsService) {} - ngOnInit() { - this.showCurrentTab = !this.popupUtilsService.inPopout(window); + this.showCurrentTab = !BrowserPopupUtils.inPopout(window); } } diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts index 2231b2ab595..eae0f12ad94 100644 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts @@ -4,7 +4,7 @@ import { Component, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CalloutModule } from "@bitwarden/components"; -import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { FilePopoutUtilsService } from "../services/file-popout-utils.service"; @Component({ @@ -19,10 +19,7 @@ export class FilePopoutCalloutComponent implements OnInit { protected showSafariFileWarning: boolean; protected showChromiumFileWarning: boolean; - constructor( - private popupUtilsService: PopupUtilsService, - private filePopoutUtilsService: FilePopoutUtilsService - ) {} + constructor(private filePopoutUtilsService: FilePopoutUtilsService) {} ngOnInit() { this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window); @@ -32,6 +29,6 @@ export class FilePopoutCalloutComponent implements OnInit { } popOutWindow() { - this.popupUtilsService.popOut(window); + BrowserPopupUtils.openCurrentPagePopout(window); } } diff --git a/apps/browser/src/tools/popup/send/send-add-edit.component.ts b/apps/browser/src/tools/popup/send/send-add-edit.component.ts index ae81779b08a..5ad664d871e 100644 --- a/apps/browser/src/tools/popup/send/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send/send-add-edit.component.ts @@ -15,8 +15,8 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { DialogService } from "@bitwarden/components"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; import { FilePopoutUtilsService } from "../services/file-popout-utils.service"; @Component({ @@ -44,7 +44,6 @@ export class SendAddEditComponent extends BaseAddEditComponent { private route: ActivatedRoute, private router: Router, private location: Location, - private popupUtilsService: PopupUtilsService, logService: LogService, sendApiService: SendApiService, dialogService: DialogService, @@ -68,14 +67,14 @@ export class SendAddEditComponent extends BaseAddEditComponent { } popOutWindow() { - this.popupUtilsService.popOut(window); + BrowserPopupUtils.openCurrentPagePopout(window); } async ngOnInit() { // File visibility this.showFileSelector = !this.editMode && !this.filePopoutUtilsService.showFilePopoutMessage(window); - this.inPopout = this.popupUtilsService.inPopout(window); + this.inPopout = BrowserPopupUtils.inPopout(window); this.isFirefox = this.platformUtilsService.isFirefox(); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe diff --git a/apps/browser/src/tools/popup/send/send-groupings.component.ts b/apps/browser/src/tools/popup/send/send-groupings.component.ts index e93064fe80b..0fde93df8fe 100644 --- a/apps/browser/src/tools/popup/send/send-groupings.component.ts +++ b/apps/browser/src/tools/popup/send/send-groupings.component.ts @@ -17,8 +17,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { DialogService } from "@bitwarden/components"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; const ComponentId = "SendComponent"; @@ -43,7 +43,6 @@ export class SendGroupingsComponent extends BaseSendComponent { ngZone: NgZone, policyService: PolicyService, searchService: SearchService, - private popupUtils: PopupUtilsService, private stateService: BrowserStateService, private router: Router, private syncService: SyncService, @@ -74,7 +73,7 @@ export class SendGroupingsComponent extends BaseSendComponent { async ngOnInit() { // Determine Header details this.showLeftHeader = !( - this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() + BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() ); // Clear state of Send Type Component await this.stateService.setBrowserSendTypeComponentState(null); @@ -97,7 +96,7 @@ export class SendGroupingsComponent extends BaseSendComponent { } if (!this.syncService.syncInProgress || restoredScopeState) { - window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0); + BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } // Load all sends if sync completed in background @@ -172,7 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent { private async saveState() { this.state = Object.assign(new BrowserSendComponentState(), { - scrollY: this.popupUtils.getContentScrollY(window), + scrollY: BrowserPopupUtils.getContentScrollY(window), searchText: this.searchText, sends: this.sends, typeCounts: this.typeCounts, diff --git a/apps/browser/src/tools/popup/send/send-type.component.ts b/apps/browser/src/tools/popup/send/send-type.component.ts index 4a2794fdda5..6e5e76a0e5d 100644 --- a/apps/browser/src/tools/popup/send/send-type.component.ts +++ b/apps/browser/src/tools/popup/send/send-type.component.ts @@ -18,8 +18,8 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service. import { DialogService } from "@bitwarden/components"; import { BrowserComponentState } from "../../../models/browserComponentState"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; const ComponentId = "SendTypeComponent"; @@ -42,7 +42,6 @@ export class SendTypeComponent extends BaseSendComponent { ngZone: NgZone, policyService: PolicyService, searchService: SearchService, - private popupUtils: PopupUtilsService, private stateService: BrowserStateService, private route: ActivatedRoute, private location: Location, @@ -102,7 +101,7 @@ export class SendTypeComponent extends BaseSendComponent { // Restore state and remove reference if (this.applySavedState && this.state != null) { - window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0); + BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } this.stateService.setBrowserSendTypeComponentState(null); }); @@ -163,7 +162,7 @@ export class SendTypeComponent extends BaseSendComponent { private async saveState() { this.state = { - scrollY: this.popupUtils.getContentScrollY(window), + scrollY: BrowserPopupUtils.getContentScrollY(window), searchText: this.searchText, }; await this.stateService.setBrowserSendTypeComponentState(this.state); diff --git a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts b/apps/browser/src/tools/popup/services/file-popout-utils.service.ts index 0dde2b1a3d2..65a311e47c3 100644 --- a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts +++ b/apps/browser/src/tools/popup/services/file-popout-utils.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; /** * Service for determining whether to display file popout callout messages. @@ -12,10 +12,7 @@ export class FilePopoutUtilsService { /** * Creates an instance of FilePopoutUtilsService. */ - constructor( - private platformUtilsService: PlatformUtilsService, - private popupUtilsService: PopupUtilsService - ) {} + constructor(private platformUtilsService: PlatformUtilsService) {} /** * Determines whether to show any file popout callout message in the current browser. @@ -38,7 +35,7 @@ export class FilePopoutUtilsService { showFirefoxFileWarning(win: Window): boolean { return ( this.platformUtilsService.isFirefox() && - !(this.popupUtilsService.inSidebar(win) || this.popupUtilsService.inPopout(win)) + !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) ); } @@ -48,7 +45,7 @@ export class FilePopoutUtilsService { * @returns True if the extension is not in a popout; otherwise, false. */ showSafariFileWarning(win: Window): boolean { - return this.platformUtilsService.isSafari() && !this.popupUtilsService.inPopout(win); + return this.platformUtilsService.isSafari() && !BrowserPopupUtils.inPopout(win); } /** @@ -60,7 +57,7 @@ export class FilePopoutUtilsService { return ( (this.isLinux(win) || this.isUnsupportedMac(win)) && !this.platformUtilsService.isFirefox() && - !(this.popupUtilsService.inSidebar(win) || this.popupUtilsService.inPopout(win)) + !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) ); } diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts index 5eab5849c41..71aae01c549 100644 --- a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts @@ -29,7 +29,7 @@ import { } from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { BrowserPopoutWindowService } from "../../platform/popup/abstractions/browser-popout-window.service"; +import { closeFido2Popout, openFido2Popout } from "../popup/utils/vault-popout-window"; const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage"; @@ -116,10 +116,7 @@ export type BrowserFido2Message = { sessionId: string } & ( * The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it. */ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { - constructor( - private browserPopoutWindowService: BrowserPopoutWindowService, - private authService: AuthService - ) {} + constructor(private authService: AuthService) {} async newSession( fallbackSupported: boolean, @@ -127,7 +124,6 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi abortController?: AbortController ): Promise { return await BrowserFido2UserInterfaceSession.create( - this.browserPopoutWindowService, this.authService, fallbackSupported, tab, @@ -138,14 +134,12 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession { static async create( - browserPopoutWindowService: BrowserPopoutWindowService, authService: AuthService, fallbackSupported: boolean, tab: chrome.tabs.Tab, abortController?: AbortController ): Promise { return new BrowserFido2UserInterfaceSession( - browserPopoutWindowService, authService, fallbackSupported, tab, @@ -183,7 +177,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi private destroy$ = new Subject(); private constructor( - private readonly browserPopoutWindowService: BrowserPopoutWindowService, private readonly authService: AuthService, private readonly fallbackSupported: boolean, private readonly tab: chrome.tabs.Tab, @@ -304,7 +297,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi } async close() { - await this.browserPopoutWindowService.closeFido2Popout(); + await closeFido2Popout(this.sessionId); this.closed = true; this.destroy$.next(); this.destroy$.complete(); @@ -354,9 +347,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) ); - const popoutId = await this.browserPopoutWindowService.openFido2Popout(this.tab, { + const popoutId = await openFido2Popout(this.tab, { sessionId: this.sessionId, - senderTabId: this.tab.id, fallbackSupported: this.fallbackSupported, }); diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 6f0bf96deec..c0862ab5081 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -35,6 +35,7 @@ import { BrowserFido2Message, BrowserFido2UserInterfaceSession, } from "../../../fido2/browser-fido2-user-interface.service"; +import { VaultPopoutType } from "../../utils/vault-popout-window"; interface ViewData { message: BrowserFido2Message; @@ -280,6 +281,7 @@ export class Fido2Component implements OnInit, OnDestroy { uilocation: "popout", senderTabId: this.senderTabId, sessionId: this.sessionId, + singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`, }, }); } @@ -299,6 +301,7 @@ export class Fido2Component implements OnInit, OnDestroy { senderTabId: this.senderTabId, sessionId: this.sessionId, userVerification: data.userVerification, + singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`, }, }); } diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 9b832148a86..9dbf619676a 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -24,11 +24,13 @@ import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserApi } from "../../../../platform/browser/browser-api"; -import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; +import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service"; import { BrowserFido2UserInterfaceSession, fido2PopoutSessionData$, } from "../../../fido2/browser-fido2-user-interface.service"; +import { VaultPopoutType, closeAddEditVaultItemPopout } from "../../utils/vault-popout-window"; @Component({ selector: "app-vault-add-edit", @@ -40,9 +42,7 @@ export class AddEditComponent extends BaseAddEditComponent { showAttachments = true; openAttachmentsInPopup: boolean; showAutoFillOnPageLoadOptions: boolean; - senderTabId?: number; - uilocation?: "popout" | "popup" | "sidebar" | "tab"; - inPopout = false; + private singleActionKey: string; private fido2PopoutSessionData$ = fido2PopoutSessionData$(); @@ -60,7 +60,7 @@ export class AddEditComponent extends BaseAddEditComponent { private location: Location, eventCollectionService: EventCollectionService, policyService: PolicyService, - private popupUtilsService: PopupUtilsService, + private popupCloseWarningService: PopupCloseWarningService, organizationService: OrganizationService, passwordRepromptService: PasswordRepromptService, logService: LogService, @@ -91,9 +91,6 @@ export class AddEditComponent extends BaseAddEditComponent { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { - this.senderTabId = parseInt(params?.senderTabId, 10) || undefined; - this.uilocation = params?.uilocation; - if (params.cipherId) { this.cipherId = params.cipherId; } @@ -119,18 +116,16 @@ export class AddEditComponent extends BaseAddEditComponent { if (params.selectedVault) { this.organizationId = params.selectedVault; } + if (params.singleActionKey) { + this.singleActionKey = params.singleActionKey; + } await this.load(); if (!this.editMode || this.cloneMode) { - if ( - !this.popupUtilsService.inPopout(window) && - params.name && - (this.cipher.name == null || this.cipher.name === "") - ) { + if (params.name && (this.cipher.name == null || this.cipher.name === "")) { this.cipher.name = params.name; } if ( - !this.popupUtilsService.inPopout(window) && params.uri && (this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].uri === "") ) { @@ -138,11 +133,9 @@ export class AddEditComponent extends BaseAddEditComponent { } } - this.openAttachmentsInPopup = this.popupUtilsService.inPopup(window); + this.openAttachmentsInPopup = BrowserPopupUtils.inPopup(window); }); - this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window); - if (!this.editMode) { const tabs = await BrowserApi.tabsQuery({ windowType: "normal" }); this.currentUris = @@ -153,8 +146,8 @@ export class AddEditComponent extends BaseAddEditComponent { this.setFocus(); - if (this.popupUtilsService.inTab(window)) { - this.popupUtilsService.enableCloseTabWarning(); + if (BrowserPopupUtils.inPopout(window)) { + this.popupCloseWarningService.enable(); } } @@ -167,12 +160,10 @@ export class AddEditComponent extends BaseAddEditComponent { async submit(): Promise { const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$); - // Would be refactored after rework is done on the windows popout service - const { isFido2Session, sessionId, userVerification } = fido2SessionData; + const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session; if ( - this.inPopout && - isFido2Session && + inFido2PopoutWindow && !(await this.handleFido2UserVerification(sessionId, userVerification)) ) { return false; @@ -183,7 +174,11 @@ export class AddEditComponent extends BaseAddEditComponent { return false; } - if (this.inPopout && isFido2Session) { + if (BrowserPopupUtils.inPopout(window)) { + this.popupCloseWarningService.disable(); + } + + if (inFido2PopoutWindow) { BrowserFido2UserInterfaceSession.confirmNewCredentialResponse( sessionId, this.cipher.id, @@ -192,14 +187,8 @@ export class AddEditComponent extends BaseAddEditComponent { return true; } - if (this.popupUtilsService.inTab(window)) { - this.popupUtilsService.disableCloseTabWarning(); - this.messagingService.send("closeTab", { delay: 1000 }); - return true; - } - - if (this.senderTabId && this.inPopout) { - setTimeout(() => this.close(), 1000); + if (this.inAddEditPopoutWindow()) { + await closeAddEditVaultItemPopout(1000); return true; } @@ -219,7 +208,7 @@ export class AddEditComponent extends BaseAddEditComponent { .createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipher.id } }) .toString(); const currentBaseUrl = window.location.href.replace(this.router.url, ""); - this.popupUtilsService.popOut(window, currentBaseUrl + destinationUrl); + BrowserPopupUtils.openCurrentPagePopout(window, currentBaseUrl + destinationUrl); } else { this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipher.id } }); } @@ -235,34 +224,21 @@ export class AddEditComponent extends BaseAddEditComponent { async cancel() { super.cancel(); - // Would be refactored after rework is done on the windows popout service const sessionData = await firstValueFrom(this.fido2PopoutSessionData$); - if (this.inPopout && sessionData.isFido2Session) { + if (BrowserPopupUtils.inPopout(window) && sessionData.isFido2Session) { + this.popupCloseWarningService.disable(); BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId); return; } - if (this.senderTabId && this.inPopout) { - this.close(); - return; - } - - if (this.popupUtilsService.inTab(window)) { - this.messagingService.send("closeTab"); + if (this.inAddEditPopoutWindow()) { + closeAddEditVaultItemPopout(); return; } this.location.back(); } - // Used for closing single-action views - close() { - BrowserApi.focusTab(this.senderTabId); - window.close(); - - return; - } - async generateUsername(): Promise { const confirmed = await super.generateUsername(); if (confirmed) { @@ -356,4 +332,11 @@ export class AddEditComponent extends BaseAddEditComponent { this.i18nService.t("autofillOnPageLoadSetToDefault") ); } + + private inAddEditPopoutWindow() { + return BrowserPopupUtils.inSingleActionPopout( + window, + this.singleActionKey || VaultPopoutType.addEditVaultItem + ); + } } diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index 4f1ceb466de..a7a40131a12 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -19,7 +19,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../../../../platform/browser/browser-api"; -import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { VaultFilterService } from "../../../services/vault-filter.service"; const BroadcasterSubscriptionId = "CurrentTabComponent"; @@ -55,7 +55,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy { constructor( private platformUtilsService: PlatformUtilsService, private cipherService: CipherService, - private popupUtilsService: PopupUtilsService, private autofillService: AutofillService, private i18nService: I18nService, private router: Router, @@ -72,7 +71,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy { async ngOnInit() { this.searchTypeSearch = !this.platformUtilsService.isSafari(); - this.inSidebar = this.popupUtilsService.inSidebar(window); + this.inSidebar = BrowserPopupUtils.inSidebar(window); this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { this.ngZone.run(async () => { @@ -185,7 +184,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy { if (this.totpCode != null) { this.platformUtilsService.copyToClipboard(this.totpCode, { window: window }); } - if (this.popupUtilsService.inPopup(window)) { + if (BrowserPopupUtils.inPopup(window)) { if (!closePopupDelay) { if (this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari()) { BrowserApi.closePopup(window); diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 4be0fe89fe1..f6d0ae96fd7 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -19,8 +19,8 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultComponent"; @@ -80,7 +80,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, private route: ActivatedRoute, - private popupUtils: PopupUtilsService, private syncService: SyncService, private platformUtilsService: PlatformUtilsService, private searchService: SearchService, @@ -94,7 +93,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { async ngOnInit() { this.searchTypeSearch = !this.platformUtilsService.isSafari(); this.showLeftHeader = !( - this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() + BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() ); await this.browserStateService.setBrowserVaultItemsComponentState(null); @@ -136,7 +135,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } if (!this.syncService.syncInProgress || restoredScopeState) { - window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0); + BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } }); } @@ -265,7 +264,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.preventSelected = true; await this.cipherService.updateLastLaunchedDate(cipher.id); BrowserApi.createNewTab(cipher.login.launchUri); - if (this.popupUtils.inPopup(window)) { + if (BrowserPopupUtils.inPopup(window)) { BrowserApi.closePopup(window); } } @@ -376,7 +375,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private async saveState() { this.state = Object.assign(new BrowserGroupingsComponentState(), { - scrollY: this.popupUtils.getContentScrollY(window), + scrollY: BrowserPopupUtils.getContentScrollY(window), searchText: this.searchText, favoriteCiphers: this.favoriteCiphers, noFolderCiphers: this.noFolderCiphers, diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index 59d7ae92c28..32e7a9eacda 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -13,7 +13,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -21,8 +20,8 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserComponentState } from "../../../../models/browserComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; -import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultItemsComponent"; @@ -61,9 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, private stateService: BrowserStateService, - private popupUtils: PopupUtilsService, private i18nService: I18nService, - private folderService: FolderService, private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService, cipherService: CipherService, @@ -155,11 +152,10 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn } if (this.applySavedState && this.state != null) { - window.setTimeout( - () => - this.popupUtils.setContentScrollY(window, this.state.scrollY, this.scrollingContainer), - 0 - ); + BrowserPopupUtils.setContentScrollY(window, this.state.scrollY, { + delay: 0, + containerSelector: this.scrollingContainer, + }); } await this.stateService.setBrowserVaultItemsComponentState(null); }); @@ -219,7 +215,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn this.preventSelected = true; await this.cipherService.updateLastLaunchedDate(cipher.id); BrowserApi.createNewTab(cipher.login.launchUri); - if (this.popupUtils.inPopup(window)) { + if (BrowserPopupUtils.inPopup(window)) { BrowserApi.closePopup(window); } } @@ -288,7 +284,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private async saveState() { this.state = { - scrollY: this.popupUtils.getContentScrollY(window, this.scrollingContainer), + scrollY: BrowserPopupUtils.getContentScrollY(window, this.scrollingContainer), searchText: this.searchText, }; await this.stateService.setBrowserVaultItemsComponentState(this.state); diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index b29814ed559..6e7adb62103 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -28,7 +28,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../../../../platform/browser/browser-api"; -import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { BrowserFido2UserInterfaceSession, fido2PopoutSessionData$, @@ -84,7 +84,6 @@ export class ViewComponent extends BaseViewComponent { eventCollectionService: EventCollectionService, private autofillService: AutofillService, private messagingService: MessagingService, - private popupUtilsService: PopupUtilsService, apiService: ApiService, passwordRepromptService: PasswordRepromptService, logService: LogService, @@ -121,7 +120,7 @@ export class ViewComponent extends BaseViewComponent { this.uilocation = value?.uilocation; }); - this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window); + this.inPopout = this.uilocation === "popout" || BrowserPopupUtils.inPopout(window); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { diff --git a/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts new file mode 100644 index 00000000000..06fb25665b3 --- /dev/null +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts @@ -0,0 +1,149 @@ +import { mock } from "jest-mock-extended"; + +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +import { + closeAddEditVaultItemPopout, + closeFido2Popout, + openAddEditVaultItemPopout, + openFido2Popout, + openVaultItemPasswordRepromptPopout, + VaultPopoutType, +} from "./vault-popout-window"; + +describe("VaultPopoutWindow", () => { + const openPopoutSpy = jest + .spyOn(BrowserPopupUtils, "openPopout") + .mockResolvedValue(mock({ id: 10 })); + const closeSingleActionPopoutSpy = jest + .spyOn(BrowserPopupUtils, "closeSingleActionPopout") + .mockImplementation(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("openVaultItemPasswordRepromptPopout", () => { + it("opens a popout window that facilitates re-prompting for the password of a vault item", async () => { + const senderTab = { windowId: 1 } as chrome.tabs.Tab; + + await openVaultItemPasswordRepromptPopout(senderTab, { + cipherId: "cipherId", + action: "action", + }); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/view-cipher?cipherId=cipherId&senderTabId=undefined&action=action", + { + singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_cipherId`, + senderWindowId: 1, + forceCloseExistingWindows: true, + } + ); + }); + }); + + describe("openAddEditVaultItemPopout", () => { + it("opens a popout window that facilitates adding a vault item", async () => { + await openAddEditVaultItemPopout( + mock({ windowId: 1, url: "https://tacos.com" }) + ); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/edit-cipher?uilocation=popout&uri=https://tacos.com", + { + singleActionKey: VaultPopoutType.addEditVaultItem, + senderWindowId: 1, + } + ); + }); + + it("opens a popout window that facilitates adding a specific type of vault item", () => { + openAddEditVaultItemPopout(mock({ windowId: 1, url: "https://tacos.com" }), { + cipherType: CipherType.Identity, + }); + + expect(openPopoutSpy).toHaveBeenCalledWith( + `popup/index.html#/edit-cipher?uilocation=popout&type=${CipherType.Identity}&uri=https://tacos.com`, + { + singleActionKey: `${VaultPopoutType.addEditVaultItem}_${CipherType.Identity}`, + senderWindowId: 1, + } + ); + }); + + it("opens a popout window that facilitates editing a vault item", async () => { + await openAddEditVaultItemPopout( + mock({ windowId: 1, url: "https://tacos.com" }), + { + cipherId: "cipherId", + } + ); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/edit-cipher?uilocation=popout&cipherId=cipherId&uri=https://tacos.com", + { + singleActionKey: `${VaultPopoutType.addEditVaultItem}_cipherId`, + senderWindowId: 1, + } + ); + }); + }); + + describe("closeAddEditVaultItemPopout", () => { + it("closes the add/edit vault item popout window", () => { + closeAddEditVaultItemPopout(); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(VaultPopoutType.addEditVaultItem, 0); + }); + + it("closes the add/edit vault item popout window after a delay", () => { + closeAddEditVaultItemPopout(1000); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith( + VaultPopoutType.addEditVaultItem, + 1000 + ); + }); + }); + + describe("openFido2Popout", () => { + it("opens a popout window that facilitates FIDO2 authentication workflows", async () => { + const senderTab = mock({ + windowId: 1, + url: "https://jest-testing.com", + id: 2, + }); + + const returnedWindowId = await openFido2Popout(senderTab, { + sessionId: "sessionId", + fallbackSupported: true, + }); + + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/fido2?sessionId=sessionId&fallbackSupported=true&senderTabId=2&senderUrl=https%3A%2F%2Fjest-testing.com", + { + singleActionKey: `${VaultPopoutType.fido2Popout}_sessionId`, + senderWindowId: 1, + forceCloseExistingWindows: true, + windowOptions: { height: 450 }, + } + ); + expect(returnedWindowId).toEqual(10); + }); + }); + + describe("closeFido2Popout", () => { + it("closes the fido2 popout window", () => { + const sessionId = "sessionId"; + + closeFido2Popout(sessionId); + + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith( + `${VaultPopoutType.fido2Popout}_${sessionId}` + ); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/utils/vault-popout-window.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.ts new file mode 100644 index 00000000000..3989aa076ac --- /dev/null +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.ts @@ -0,0 +1,129 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; + +const VaultPopoutType = { + vaultItemPasswordReprompt: "vault_PasswordReprompt", + addEditVaultItem: "vault_AddEditVaultItem", + fido2Popout: "vault_Fido2Popout", +} as const; + +/** + * Opens a popout window that facilitates re-prompting for + * the password of a vault item. + * + * @param senderTab - The tab that sent the request. + * @param cipherOptions - The cipher id and action to perform. + */ +async function openVaultItemPasswordRepromptPopout( + senderTab: chrome.tabs.Tab, + cipherOptions: { + cipherId: string; + action: string; + } +) { + const { cipherId, action } = cipherOptions; + const promptWindowPath = + "popup/index.html#/view-cipher" + + `?cipherId=${cipherId}` + + `&senderTabId=${senderTab.id}` + + `&action=${action}`; + + await BrowserPopupUtils.openPopout(promptWindowPath, { + singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_${cipherId}`, + senderWindowId: senderTab.windowId, + forceCloseExistingWindows: true, + }); +} + +/** + * Opens a popout window that facilitates adding or editing a vault item. + * + * @param senderTab - The window id of the sender. + * @param cipherOptions - Options passed as query params to the popout. + */ +async function openAddEditVaultItemPopout( + senderTab: chrome.tabs.Tab, + cipherOptions: { cipherId?: string; cipherType?: CipherType } = {} +) { + const { cipherId, cipherType } = cipherOptions; + const { url, windowId } = senderTab; + + let singleActionKey = VaultPopoutType.addEditVaultItem; + let addEditCipherUrl = "popup/index.html#/edit-cipher?uilocation=popout"; + if (cipherId && !cipherType) { + singleActionKey += `_${cipherId}`; + addEditCipherUrl += `&cipherId=${cipherId}`; + } + if (cipherType && !cipherId) { + singleActionKey += `_${cipherType}`; + addEditCipherUrl += `&type=${cipherType}`; + } + if (senderTab.url) { + addEditCipherUrl += `&uri=${url}`; + } + + await BrowserPopupUtils.openPopout(addEditCipherUrl, { + singleActionKey, + senderWindowId: windowId, + }); +} + +/** + * Closes the add/edit vault item popout window. + * + * @param delayClose - The amount of time to wait before closing the popout. Defaults to 0. + */ +async function closeAddEditVaultItemPopout(delayClose = 0) { + await BrowserPopupUtils.closeSingleActionPopout(VaultPopoutType.addEditVaultItem, delayClose); +} + +/** + * Opens a popout window that facilitates FIDO2 + * authentication and passkey management. + * + * @param senderTab - The tab that sent the request. + * @param options - Options passed as query params to the popout. + */ +async function openFido2Popout( + senderTab: chrome.tabs.Tab, + options: { + sessionId: string; + fallbackSupported: boolean; + } +): Promise { + const { sessionId, fallbackSupported } = options; + const promptWindowPath = + "popup/index.html#/fido2" + + `?sessionId=${sessionId}` + + `&fallbackSupported=${fallbackSupported}` + + `&senderTabId=${senderTab.id}` + + `&senderUrl=${encodeURIComponent(senderTab.url)}`; + + const popoutWindow = await BrowserPopupUtils.openPopout(promptWindowPath, { + singleActionKey: `${VaultPopoutType.fido2Popout}_${sessionId}`, + senderWindowId: senderTab.windowId, + forceCloseExistingWindows: true, + windowOptions: { height: 450 }, + }); + + return popoutWindow.id; +} + +/** + * Closes the FIDO2 popout window associated with the passed session ID. + * + * @param sessionId - The session ID of the popout to close. + */ +async function closeFido2Popout(sessionId: string): Promise { + await BrowserPopupUtils.closeSingleActionPopout(`${VaultPopoutType.fido2Popout}_${sessionId}`); +} + +export { + VaultPopoutType, + openVaultItemPasswordRepromptPopout, + openAddEditVaultItemPopout, + closeAddEditVaultItemPopout, + openFido2Popout, + closeFido2Popout, +}; diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 6feb163e0a6..b6d2fce9db8 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -23,6 +23,7 @@ const runtime = { }, sendMessage: jest.fn(), getManifest: jest.fn(), + getURL: jest.fn((path) => `chrome-extension://id/${path}`), }; const contextMenus = { @@ -37,12 +38,21 @@ const i18n = { const tabs = { executeScript: jest.fn(), sendMessage: jest.fn(), + query: jest.fn(), }; const scripting = { executeScript: jest.fn(), }; +const windows = { + create: jest.fn(), + get: jest.fn(), + getCurrent: jest.fn(), + update: jest.fn(), + remove: jest.fn(), +}; + // set chrome global.chrome = { i18n, @@ -51,4 +61,5 @@ global.chrome = { contextMenus, tabs, scripting, + windows, } as any;