1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-3914] Refactor Browser Extension Popout Windows (#6296)

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Adding enums for the browser popout type

* [PM-3914] Making the methods for getting a window in a targeted manner public

* [PM-3914] Refactoing implementation

* [PM-3914] Updating deprecated api call

* [PM-3914] Fixing issues found when testing behavior

* [PM-3914] Reimplementing behavior based on feedback from platform team

* [PM-3914] Adding method of ensuring previously opened single action window is force closed for vault item password reprompts

* [PM-3914] Taking into consideration feedback regarding the browser popup utils service and implementating requested changes

* [PM-3914] Removing unnecesssary class dependencies

* [PM-3914] Adding method for uniquely setting up password reprompt windows

* [PM-3914] Modifying method

* [PM-3914] Adding jest tests and documentation for AuthPopoutWindow util

* [PM-3914] Adding jest tests and documentation for VaultPopoutWindow

* [PM-3914] Adding jest tests for the debouncing method within autofill service

* [PM-3914] Adding jest tests for the new BrowserApi methods

* [PM-3914] Adding jest tests to the BrowserPopupUtils class

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Modifying comment

* [PM-3914] Moviing implementation for openCurrentPagePopout to the BrowserPopupUtils

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3983] Refactoring implementation of `setContentScrollY` to facilitate having a potential delay

* [PM-3914] Applying feedback regarding setContentScrollY to the implementation

* [PM-3914] Modifying early return within the run method of the ContextMenuClickedHandler

* [PM-3914] Adding test for VaultPopoutWindow

* [PM-3914] Applying work done within PM-4366 to facilitate opening the popout window as a popup rather than a normal window

* [PM-3914] Updating the BrowserApi.removeTab method to leverage a callback structure for the promise rather than an async away structure

* [PM-3036] Adding jest tests for added passkeys popout windows

* [PM-3914] Adjsuting logic for turning off the warning when FIDO2 credentials are saved

* [PM-3914] Fixing height to design

* [PM-3914] Fixing call to Fido2 Popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing jest tests for updated elements

* [PM-3914] Reverting how context menu actions are passed to the view component

* [PM-3914] Reverting re-instantiation of config service within main.background.ts

* [PM-3914] Adding jest test for BrowserAPI removeTab method

* [PM-3914] Adding method to handle parsing the popout url path

* [PM-3914] Removing JSDOC comment elements

* [PM-3914] Removing await from method call

* [PM-3914] Simplifying implementation on add/edit

* [PM-3032] Adding more direct reference to view item action in context menus

* [PM-3914] Adjusting routing on Fido2 component to pass the singleActionPopout param to the route when opening the add-edit component

* [PM-3914] Adding singleActionPopout param to the fido2 component routing

* [PM-3914] Updating implementation details for how we build the extension url path

* [PM-3914] Reworking implementation for isSingleActionPopoutOpen to clean up iterative logic

* [PM-3914] Merging work from master and fixing merge conflicts

* [PM-3914] Fixing merge conflict introduced from master

* [PM-3914] Reworking closure of single action popouts to ensure they close the window instead of attempting to close the tab

* [PM-3914] Fixing issue within Opera where lock and login routes can persist if user opens the extension popout in a new window before locking or logging out

* [PM-3914] Setting the extensionUrls that are cheked as a variable outside of the scope fo the openUlockPopout method to ensure it does not have to be rebuilt each time the method is called
This commit is contained in:
Cesar Gonzalez
2023-11-08 12:57:44 -06:00
committed by GitHub
parent 16c567ab59
commit cf6ada531e
42 changed files with 1579 additions and 677 deletions

View File

@@ -22,7 +22,9 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../platform/browser/browser-api"; 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"; const BroadcasterSubscriptionId = "TwoFactorComponent";
@@ -31,8 +33,6 @@ const BroadcasterSubscriptionId = "TwoFactorComponent";
templateUrl: "two-factor.component.html", templateUrl: "two-factor.component.html",
}) })
export class TwoFactorComponent extends BaseTwoFactorComponent { export class TwoFactorComponent extends BaseTwoFactorComponent {
showNewWindowMessage = false;
constructor( constructor(
authService: AuthService, authService: AuthService,
router: Router, router: Router,
@@ -42,7 +42,6 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
private syncService: SyncService, private syncService: SyncService,
environmentService: EnvironmentService, environmentService: EnvironmentService,
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
private popupUtilsService: PopupUtilsService,
stateService: StateService, stateService: StateService,
route: ActivatedRoute, route: ActivatedRoute,
private messagingService: MessagingService, private messagingService: MessagingService,
@@ -115,7 +114,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
if ( if (
this.selectedProviderType === TwoFactorProviderType.Email && this.selectedProviderType === TwoFactorProviderType.Email &&
this.popupUtilsService.inPopup(window) BrowserPopupUtils.inPopup(window)
) { ) {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" }, title: { key: "warning" },
@@ -123,7 +122,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
type: "warning", type: "warning",
}); });
if (confirmed) { 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 // 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) // on the web vault screen which tells them to continue in the browser extension (sidebar or popup)
BrowserApi.closeBitwardenExtensionTab(); await closeTwoFactorAuthPopout();
}; };
} }
}); });

View File

@@ -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");
});
});
});

View File

@@ -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,
};

View File

@@ -12,6 +12,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; 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 AddUnlockVaultQueueMessage from "../../background/models/add-unlock-vault-queue-message";
import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage"; import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage";
import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage"; import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage";
@@ -21,6 +22,7 @@ import LockedVaultPendingNotificationsItem from "../../background/models/lockedV
import { NotificationQueueMessageType } from "../../background/models/notificationQueueMessageType"; import { NotificationQueueMessageType } from "../../background/models/notificationQueueMessageType";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; 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"; import { AutofillService } from "../services/abstractions/autofill.service";
export default class NotificationBackground { export default class NotificationBackground {
@@ -96,7 +98,7 @@ export default class NotificationBackground {
"addToLockedVaultPendingNotifications", "addToLockedVaultPendingNotifications",
retryMessage retryMessage
); );
await BrowserApi.tabSendMessageData(sender.tab, "promptForLogin"); await openUnlockPopout(sender.tab);
return; return;
} }
await this.saveOrUpdateCredentials(sender.tab, msg.edit, msg.folder); await this.saveOrUpdateCredentials(sender.tab, msg.edit, msg.folder);
@@ -118,9 +120,12 @@ export default class NotificationBackground {
break; break;
} }
break; break;
case "promptForLogin": case "bgUnlockPopoutOpened":
await this.unlockVault(sender.tab); await this.unlockVault(sender.tab);
break; break;
case "bgReopenUnlockPopout":
await openUnlockPopout(sender.tab);
break;
default: default:
break; break;
} }
@@ -447,9 +452,7 @@ export default class NotificationBackground {
collectionIds: cipherView.collectionIds, collectionIds: cipherView.collectionIds,
}); });
await BrowserApi.tabSendMessageData(senderTab, "openAddEditCipher", { await openAddEditVaultItemPopout(senderTab, { cipherId: cipherView.id });
cipherId: cipherView.id,
});
} }
private async folderExists(folderId: string) { private async folderExists(folderId: string) {

View File

@@ -18,6 +18,7 @@ import {
} from "../../auth/background/service-factories/auth-service.factory"; } from "../../auth/background/service-factories/auth-service.factory";
import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory"; import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory";
import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-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 LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem";
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
@@ -29,6 +30,10 @@ import {
cipherServiceFactory, cipherServiceFactory,
CipherServiceInitOptions, CipherServiceInitOptions,
} from "../../vault/background/service_factories/cipher-service.factory"; } 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 { autofillServiceFactory } from "../background/service_factories/autofill-service.factory";
import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard"; import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard";
import { AutofillTabCommand } from "../commands/autofill-tab-command"; import { AutofillTabCommand } from "../commands/autofill-tab-command";
@@ -187,7 +192,7 @@ export class ContextMenuClickedHandler {
retryMessage retryMessage
); );
await BrowserApi.tabSendMessageData(tab, "promptForLogin"); await openUnlockPopout(tab);
return; return;
} }
@@ -235,14 +240,12 @@ export class ContextMenuClickedHandler {
const cipherType = this.getCipherCreationType(menuItemId); const cipherType = this.getCipherCreationType(menuItemId);
if (cipherType) { if (cipherType) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { await openAddEditVaultItemPopout(tab, { cipherType });
cipherType,
});
break; break;
} }
if (await this.isPasswordRepromptRequired(cipher)) { if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id, cipherId: cipher.id,
// The action here is passed on to the single-use reprompt window and doesn't change based on cipher type // The action here is passed on to the single-use reprompt window and doesn't change based on cipher type
action: AUTOFILL_ID, action: AUTOFILL_ID,
@@ -255,9 +258,7 @@ export class ContextMenuClickedHandler {
} }
case COPY_USERNAME_ID: case COPY_USERNAME_ID:
if (menuItemId === CREATE_LOGIN_ID) { if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
cipherType: CipherType.Login,
});
break; break;
} }
@@ -265,16 +266,14 @@ export class ContextMenuClickedHandler {
break; break;
case COPY_PASSWORD_ID: case COPY_PASSWORD_ID:
if (menuItemId === CREATE_LOGIN_ID) { if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
cipherType: CipherType.Login,
});
break; break;
} }
if (await this.isPasswordRepromptRequired(cipher)) { if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id, cipherId: cipher.id,
action: info.parentMenuItemId, action: COPY_PASSWORD_ID,
}); });
} else { } else {
this.copyToClipboard({ text: cipher.login.password, tab: tab }); this.copyToClipboard({ text: cipher.login.password, tab: tab });
@@ -284,16 +283,14 @@ export class ContextMenuClickedHandler {
break; break;
case COPY_VERIFICATIONCODE_ID: case COPY_VERIFICATIONCODE_ID:
if (menuItemId === CREATE_LOGIN_ID) { if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
cipherType: CipherType.Login,
});
break; break;
} }
if (await this.isPasswordRepromptRequired(cipher)) { if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id, cipherId: cipher.id,
action: info.parentMenuItemId, action: COPY_VERIFICATIONCODE_ID,
}); });
} else { } else {
this.copyToClipboard({ this.copyToClipboard({

View File

@@ -28,12 +28,10 @@ window.addEventListener(
); );
const forwardCommands = [ const forwardCommands = [
"promptForLogin", "bgUnlockPopoutOpened",
"passwordReprompt",
"addToLockedVaultPendingNotifications", "addToLockedVaultPendingNotifications",
"unlockCompleted", "unlockCompleted",
"addedCipher", "addedCipher",
"openAddEditCipher",
]; ];
chrome.runtime.onMessage.addListener((event) => { chrome.runtime.onMessage.addListener((event) => {

View File

@@ -189,7 +189,7 @@ function handleTypeUnlock() {
const unlockButton = document.getElementById("unlock-vault"); const unlockButton = document.getElementById("unlock-vault");
unlockButton.addEventListener("click", (e) => { unlockButton.addEventListener("click", (e) => {
sendPlatformMessage({ sendPlatformMessage({
command: "bgReopenPromptForLogin", command: "bgReopenUnlockPopout",
}); });
}); });
} }

View File

@@ -752,13 +752,15 @@ describe("AutofillService", () => {
jest jest
.spyOn(userVerificationService, "hasMasterPasswordAndMasterKeyHash") .spyOn(userVerificationService, "hasMasterPasswordAndMasterKeyHash")
.mockResolvedValueOnce(true); .mockResolvedValueOnce(true);
jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); jest
.spyOn(autofillService as any, "openVaultItemPasswordRepromptPopout")
.mockImplementation();
const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true); const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true);
expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url); expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url);
expect(userVerificationService.hasMasterPasswordAndMasterKeyHash).toHaveBeenCalled(); expect(userVerificationService.hasMasterPasswordAndMasterKeyHash).toHaveBeenCalled();
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(tab, "passwordReprompt", { expect(autofillService["openVaultItemPasswordRepromptPopout"]).toHaveBeenCalledWith(tab, {
cipherId: cipher.id, cipherId: cipher.id,
action: "autofill", action: "autofill",
}); });
@@ -4254,4 +4256,24 @@ describe("AutofillService", () => {
expect(result).toBe(false); 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);
});
});
}); });

View File

@@ -12,6 +12,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; 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 AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details"; import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script"; import AutofillScript from "../models/autofill-script";
@@ -30,6 +31,10 @@ import {
} from "./autofill-constants"; } from "./autofill-constants";
export default class AutofillService implements AutofillServiceInterface { export default class AutofillService implements AutofillServiceInterface {
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
private currentlyOpeningPasswordRepromptPopout = false;
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
private stateService: BrowserStateService, private stateService: BrowserStateService,
@@ -272,13 +277,14 @@ export default class AutofillService implements AutofillServiceInterface {
if ( if (
cipher.reprompt === CipherRepromptType.Password && cipher.reprompt === CipherRepromptType.Password &&
// If the master password has is not available, reprompt will error // If the master password has is not available, reprompt will error
(await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) &&
!this.isDebouncingPasswordRepromptPopout()
) { ) {
if (fromCommand) { if (fromCommand) {
this.cipherService.updateLastUsedIndexForUrl(tab.url); this.cipherService.updateLastUsedIndexForUrl(tab.url);
} }
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id, cipherId: cipher.id,
action: "autofill", action: "autofill",
}); });
@@ -1828,4 +1834,22 @@ export default class AutofillService implements AutofillServiceInterface {
static forCustomFieldsOnly(field: AutofillField): boolean { static forCustomFieldsOnly(field: AutofillField): boolean {
return field.tagName === "span"; 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;
}
} }

View File

@@ -4,6 +4,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; 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 { BrowserApi } from "../platform/browser/browser-api";
import MainBackground from "./main.background"; import MainBackground from "./main.background";
@@ -87,7 +88,7 @@ export default class CommandsBackground {
retryMessage retryMessage
); );
BrowserApi.tabSendMessageData(tab, "promptForLogin"); await openUnlockPopout(tab);
return; return;
} }

View File

@@ -132,7 +132,6 @@ import { Account } from "../models/account";
import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserApi } from "../platform/browser/browser-api";
import { flagEnabled } from "../platform/flags"; import { flagEnabled } from "../platform/flags";
import { UpdateBadge } from "../platform/listeners/update-badge"; 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 { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserConfigService } from "../platform/services/browser-config.service"; import { BrowserConfigService } from "../platform/services/browser-config.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.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 { BrowserStateService } from "../platform/services/browser-state.service";
import { KeyGenerationService } from "../platform/services/key-generation.service"; import { KeyGenerationService } from "../platform/services/key-generation.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.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 { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service"; import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
@@ -225,8 +223,6 @@ export default class MainBackground {
devicesService: DevicesServiceAbstraction; devicesService: DevicesServiceAbstraction;
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
authRequestCryptoService: AuthRequestCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
popupUtilsService: PopupUtilsService;
browserPopoutWindowService: BrowserPopoutWindowService;
accountService: AccountServiceAbstraction; accountService: AccountServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc. // Passed to the popup for Safari to workaround issues with theming, downloading, etc.
@@ -583,12 +579,7 @@ export default class MainBackground {
this.messagingService this.messagingService
); );
this.browserPopoutWindowService = new BrowserPopoutWindowService(); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(
this.browserPopoutWindowService,
this.authService
);
this.fido2AuthenticatorService = new Fido2AuthenticatorService( this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService, this.cipherService,
this.fido2UserInterfaceService, this.fido2UserInterfaceService,
@@ -634,8 +625,7 @@ export default class MainBackground {
this.environmentService, this.environmentService,
this.messagingService, this.messagingService,
this.logService, this.logService,
this.configService, this.configService
this.browserPopoutWindowService
); );
this.nativeMessagingBackground = new NativeMessagingBackground( this.nativeMessagingBackground = new NativeMessagingBackground(
this.cryptoService, this.cryptoService,

View File

@@ -8,9 +8,13 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; 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 { AutofillService } from "../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../platform/browser/browser-api"; 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 { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
@@ -37,8 +41,7 @@ export default class RuntimeBackground {
private environmentService: BrowserEnvironmentService, private environmentService: BrowserEnvironmentService,
private messagingService: MessagingService, private messagingService: MessagingService,
private logService: LogService, private logService: LogService,
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction
private browserPopoutWindowService: BrowserPopoutWindowService
) { ) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor // onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => { chrome.runtime.onInstalled.addListener((details: any) => {
@@ -82,8 +85,6 @@ export default class RuntimeBackground {
} }
async processMessage(msg: any, sender: chrome.runtime.MessageSender) { async processMessage(msg: any, sender: chrome.runtime.MessageSender) {
const cipherId = msg.data?.cipherId;
switch (msg.command) { switch (msg.command) {
case "loggedIn": case "loggedIn":
case "unlocked": { case "unlocked": {
@@ -91,7 +92,7 @@ export default class RuntimeBackground {
if (this.lockedVaultPendingNotifications?.length > 0) { if (this.lockedVaultPendingNotifications?.length > 0) {
item = this.lockedVaultPendingNotifications.pop(); item = this.lockedVaultPendingNotifications.pop();
await this.browserPopoutWindowService.closeUnlockPrompt(); await closeUnlockPopout();
} }
await this.main.refreshBadge(); await this.main.refreshBadge();
@@ -129,49 +130,6 @@ export default class RuntimeBackground {
case "openPopup": case "openPopup":
await this.main.openPopup(); await this.main.openPopup();
break; 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": case "triggerAutofillScriptInjection":
await this.autofillService.injectAutofillScripts( await this.autofillService.injectAutofillScripts(
sender, sender,
@@ -266,12 +224,7 @@ export default class RuntimeBackground {
}); });
} else { } else {
try { try {
BrowserApi.createNewTab( await openSsoAuthResultPopout(msg);
"popup/index.html?uilocation=popout#/sso?code=" +
encodeURIComponent(msg.code) +
"&state=" +
encodeURIComponent(msg.state)
);
} catch { } catch {
this.logService.error("Unable to open sso popout tab"); this.logService.error("Unable to open sso popout tab");
} }
@@ -285,10 +238,7 @@ export default class RuntimeBackground {
return; return;
} }
const params = await openTwoFactorAuthPopout(msg);
`webAuthnResponse=${encodeURIComponent(msg.data)};` +
`remember=${encodeURIComponent(msg.remember)}`;
BrowserApi.openBitwardenExtensionTab(`popup/index.html#/2fa;${params}`, false);
break; break;
} }
case "reloadPopup": case "reloadPopup":

View File

@@ -9,6 +9,89 @@ describe("BrowserApi", () => {
jest.clearAllMocks(); 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", () => { describe("executeScriptInTab", () => {
it("calls to the extension api to execute a script within the give tabId", async () => { it("calls to the extension api to execute a script within the give tabId", async () => {
const tabId = 1; const tabId = 1;

View File

@@ -19,14 +19,33 @@ export class BrowserApi {
return chrome.runtime.getManifest().manifest_version; return chrome.runtime.getManifest().manifest_version;
} }
static getWindow(windowId?: number): Promise<chrome.windows.Window> | 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<chrome.windows.Window> {
if (!windowId) { if (!windowId) {
return; return BrowserApi.getCurrentWindow();
} }
return new Promise((resolve) => return await BrowserApi.getWindowById(windowId);
chrome.windows.get(windowId, { populate: true }, (window) => resolve(window)) }
);
/**
* Gets the currently active browser window.
*/
static async getCurrentWindow(): Promise<chrome.windows.Window> {
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<chrome.windows.Window> {
return new Promise((resolve) => chrome.windows.get(windowId, { populate: true }, resolve));
} }
static async createWindow(options: chrome.windows.CreateData): Promise<chrome.windows.Window> { static async createWindow(options: chrome.windows.CreateData): Promise<chrome.windows.Window> {
@@ -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<void> {
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<void> {
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<chrome.tabs.Tab> | null { static async getTabFromCurrentWindowId(): Promise<chrome.tabs.Tab> | null {
@@ -138,10 +188,6 @@ export class BrowserApi {
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback); chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback);
} }
static async removeTab(tabId: number) {
await chrome.tabs.remove(tabId);
}
static async getPrivateModeWindows(): Promise<browser.windows.Window[]> { static async getPrivateModeWindows(): Promise<browser.windows.Window[]> {
return (await browser.windows.getAll()).filter((win) => win.incognito); 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 // 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 // them when the popup gets unloaded, otherwise we cause a memory leak
private static registeredMessageListeners: any[] = []; private static registeredMessageListeners: any[] = [];

View File

@@ -1,42 +0,0 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
interface BrowserPopoutWindowService {
openUnlockPrompt(senderWindowId: number): Promise<void>;
closeUnlockPrompt(): Promise<void>;
openPasswordRepromptPrompt(
senderWindowId: number,
promptData: {
action: string;
cipherId: string;
senderTabId: number;
}
): Promise<void>;
openCipherCreation(
senderWindowId: number,
promptData: {
cipherType?: CipherType;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
openCipherEdit(
senderWindowId: number,
promptData: {
cipherId: string;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
closePasswordRepromptPrompt(): Promise<void>;
openFido2Popout(
senderWindow: chrome.tabs.Tab,
promptData: {
sessionId: string;
senderTabId: number;
fallbackSupported: boolean;
}
): Promise<number>;
closeFido2Popout(): Promise<void>;
}
export { BrowserPopoutWindowService };

View File

@@ -0,0 +1,6 @@
type ScrollOptions = {
delay: number;
containerSelector: string;
};
export { ScrollOptions };

View File

@@ -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<string, number> = {};
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<number> {
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<void> {
await this.closeSingleActionPopout("fido2Popout");
}
private async openSingleActionPopout(
senderWindowId: number,
popupWindowURL: string,
singleActionPopoutKey: string,
options: chrome.windows.CreateData = {}
): Promise<number> {
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;

View File

@@ -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 = `
<main>
<div></div>
</main>
`;
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 = `
<main>
<div></div>
</main>
`;
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 = `
<div class="scrolling-container">
<div></div>
</div>
`;
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);
});
});
});

View File

@@ -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<void>((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<chrome.windows.CreateData>;
} = {}
) {
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<void> {
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;

View File

@@ -2,7 +2,7 @@ import { Component, Input, OnInit } from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; 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({ @Component({
selector: "app-pop-out", selector: "app-pop-out",
@@ -11,16 +11,13 @@ import { PopupUtilsService } from "../services/popup-utils.service";
export class PopOutComponent implements OnInit { export class PopOutComponent implements OnInit {
@Input() show = true; @Input() show = true;
constructor( constructor(private platformUtilsService: PlatformUtilsService) {}
private platformUtilsService: PlatformUtilsService,
private popupUtilsService: PopupUtilsService
) {}
ngOnInit() { ngOnInit() {
if (this.show) { if (this.show) {
if ( if (
(this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) || (BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()) ||
this.popupUtilsService.inPopout(window) BrowserPopupUtils.inPopout(window)
) { ) {
this.show = false; this.show = false;
} }
@@ -28,6 +25,6 @@ export class PopOutComponent implements OnInit {
} }
expand() { expand() {
this.popupUtilsService.popOut(window); BrowserPopupUtils.openCurrentPagePopout(window);
} }
} }

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { PopupUtilsService } from "../services/popup-utils.service"; import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
@Component({ @Component({
selector: "app-private-mode-warning", selector: "app-private-mode-warning",
@@ -9,9 +9,7 @@ import { PopupUtilsService } from "../services/popup-utils.service";
export class PrivateModeWarningComponent implements OnInit { export class PrivateModeWarningComponent implements OnInit {
showWarning = false; showWarning = false;
constructor(private popupUtilsService: PopupUtilsService) {}
ngOnInit() { ngOnInit() {
this.showWarning = this.popupUtilsService.inPrivateMode(); this.showWarning = BrowserPopupUtils.inPrivateMode();
} }
} }

View File

@@ -6,16 +6,14 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.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 { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "./popup-utils.service";
@Injectable() @Injectable()
export class InitService { export class InitService {
constructor( constructor(
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private popupUtilsService: PopupUtilsService,
private stateService: StateServiceAbstraction, private stateService: StateServiceAbstraction,
private logService: LogServiceAbstraction, private logService: LogServiceAbstraction,
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
@@ -26,7 +24,7 @@ export class InitService {
return async () => { return async () => {
await this.stateService.init(); await this.stateService.init();
if (!this.popupUtilsService.inPopup(window)) { if (!BrowserPopupUtils.inPopup(window)) {
window.document.body.classList.add("body-full"); window.document.body.classList.add("body-full");
} else if (window.screen.availHeight < 600) { } else if (window.screen.availHeight < 600) {
window.document.body.classList.add("body-xs"); window.document.body.classList.add("body-xs");
@@ -43,7 +41,7 @@ export class InitService {
if ( if (
this.platformUtilsService.isChrome() && this.platformUtilsService.isChrome() &&
navigator.platform.indexOf("Mac") > -1 && navigator.platform.indexOf("Mac") > -1 &&
this.popupUtilsService.inPopup(window) && BrowserPopupUtils.inPopup(window) &&
(window.screenLeft < 0 || (window.screenLeft < 0 ||
window.screenTop < 0 || window.screenTop < 0 ||
window.screenLeft > window.screen.width || window.screenLeft > window.screen.width ||

View File

@@ -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();
}
}

View File

@@ -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<Popout> {
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<void> {
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();
}
}

View File

@@ -94,6 +94,7 @@ import { AutofillService } from "../../autofill/services/abstractions/autofill.s
import MainBackground from "../../background/main.background"; import MainBackground from "../../background/main.background";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { BrowserApi } from "../../platform/browser/browser-api"; 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 { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { BrowserConfigService } from "../../platform/services/browser-config.service"; import { BrowserConfigService } from "../../platform/services/browser-config.service";
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.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 { DebounceNavigationService } from "./debounceNavigationService";
import { InitService } from "./init.service"; import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service";
import { PopupSearchService } from "./popup-search.service"; import { PopupSearchService } from "./popup-search.service";
import { PopupUtilsService } from "./popup-utils.service";
const needsBackgroundInit = BrowserApi.getBackgroundPage() == null; const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired();
const isPrivateMode = needsBackgroundInit && BrowserApi.manifestVersion !== 3; const isPrivateMode = BrowserPopupUtils.inPrivateMode();
const mainBackground: MainBackground = needsBackgroundInit const mainBackground: MainBackground = needsBackgroundInit
? createLocalBgService() ? createLocalBgService()
: BrowserApi.getBackgroundPage().bitwardenMain; : BrowserApi.getBackgroundPage().bitwardenMain;
@@ -138,6 +139,7 @@ function getBgService<T>(service: keyof MainBackground) {
InitService, InitService,
DebounceNavigationService, DebounceNavigationService,
DialogService, DialogService,
PopupCloseWarningService,
{ {
provide: LOCALE_ID, provide: LOCALE_ID,
useFactory: () => getBgService<I18nServiceAbstraction>("i18nService")().translationLocale, useFactory: () => getBgService<I18nServiceAbstraction>("i18nService")().translationLocale,
@@ -150,7 +152,6 @@ function getBgService<T>(service: keyof MainBackground) {
multi: true, multi: true,
}, },
{ provide: BaseUnauthGuardService, useClass: UnauthGuardService }, { provide: BaseUnauthGuardService, useClass: UnauthGuardService },
{ provide: PopupUtilsService, useFactory: () => new PopupUtilsService(isPrivateMode) },
{ {
provide: MessagingService, provide: MessagingService,
useFactory: () => { useFactory: () => {
@@ -523,13 +524,10 @@ function getBgService<T>(service: keyof MainBackground) {
}, },
{ {
provide: FilePopoutUtilsService, provide: FilePopoutUtilsService,
useFactory: ( useFactory: (platformUtilsService: PlatformUtilsService) => {
platformUtilsService: PlatformUtilsService, return new FilePopoutUtilsService(platformUtilsService);
popupUtilsService: PopupUtilsService
) => {
return new FilePopoutUtilsService(platformUtilsService, popupUtilsService);
}, },
deps: [PlatformUtilsService, PopupUtilsService], deps: [PlatformUtilsService],
}, },
], ],
}) })

View File

@@ -36,7 +36,7 @@ import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; import { SetPinComponent } from "../../auth/popup/components/set-pin.component";
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors"; import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors";
import { BrowserApi } from "../../platform/browser/browser-api"; 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 { AboutComponent } from "./about.component";
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
@@ -95,7 +95,6 @@ export class SettingsComponent implements OnInit {
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private cryptoService: CryptoService, private cryptoService: CryptoService,
private stateService: StateService, private stateService: StateService,
private popupUtilsService: PopupUtilsService,
private modalService: ModalService, private modalService: ModalService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private dialogService: DialogService, private dialogService: DialogService,
@@ -335,7 +334,7 @@ export class SettingsComponent implements OnInit {
// eslint-disable-next-line // eslint-disable-next-line
console.error(e); console.error(e);
if (this.platformUtilsService.isFirefox() && this.popupUtilsService.inSidebar(window)) { if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) {
await this.dialogService.openSimpleDialog({ await this.dialogService.openSimpleDialog({
title: { key: "nativeMessaginPermissionSidebarTitle" }, title: { key: "nativeMessaginPermissionSidebarTitle" },
content: { key: "nativeMessaginPermissionSidebarDesc" }, content: { key: "nativeMessaginPermissionSidebarDesc" },
@@ -474,7 +473,7 @@ export class SettingsComponent implements OnInit {
async import() { async import() {
await this.router.navigate(["/import"]); await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) { if (await BrowserApi.isPopupOpen()) {
this.popupUtilsService.popOut(window); BrowserPopupUtils.openCurrentPagePopout(window);
} }
} }

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { PopupUtilsService } from "./services/popup-utils.service"; import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
@Component({ @Component({
selector: "app-tabs", selector: "app-tabs",
@@ -9,9 +9,7 @@ import { PopupUtilsService } from "./services/popup-utils.service";
export class TabsComponent implements OnInit { export class TabsComponent implements OnInit {
showCurrentTab = true; showCurrentTab = true;
constructor(private popupUtilsService: PopupUtilsService) {}
ngOnInit() { ngOnInit() {
this.showCurrentTab = !this.popupUtilsService.inPopout(window); this.showCurrentTab = !BrowserPopupUtils.inPopout(window);
} }
} }

View File

@@ -4,7 +4,7 @@ import { Component, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CalloutModule } from "@bitwarden/components"; 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"; import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
@Component({ @Component({
@@ -19,10 +19,7 @@ export class FilePopoutCalloutComponent implements OnInit {
protected showSafariFileWarning: boolean; protected showSafariFileWarning: boolean;
protected showChromiumFileWarning: boolean; protected showChromiumFileWarning: boolean;
constructor( constructor(private filePopoutUtilsService: FilePopoutUtilsService) {}
private popupUtilsService: PopupUtilsService,
private filePopoutUtilsService: FilePopoutUtilsService
) {}
ngOnInit() { ngOnInit() {
this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window); this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window);
@@ -32,6 +29,6 @@ export class FilePopoutCalloutComponent implements OnInit {
} }
popOutWindow() { popOutWindow() {
this.popupUtilsService.popOut(window); BrowserPopupUtils.openCurrentPagePopout(window);
} }
} }

View File

@@ -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 { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; 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"; import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
@Component({ @Component({
@@ -44,7 +44,6 @@ export class SendAddEditComponent extends BaseAddEditComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private location: Location, private location: Location,
private popupUtilsService: PopupUtilsService,
logService: LogService, logService: LogService,
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
@@ -68,14 +67,14 @@ export class SendAddEditComponent extends BaseAddEditComponent {
} }
popOutWindow() { popOutWindow() {
this.popupUtilsService.popOut(window); BrowserPopupUtils.openCurrentPagePopout(window);
} }
async ngOnInit() { async ngOnInit() {
// File visibility // File visibility
this.showFileSelector = this.showFileSelector =
!this.editMode && !this.filePopoutUtilsService.showFilePopoutMessage(window); !this.editMode && !this.filePopoutUtilsService.showFilePopoutMessage(window);
this.inPopout = this.popupUtilsService.inPopout(window); this.inPopout = BrowserPopupUtils.inPopout(window);
this.isFirefox = this.platformUtilsService.isFirefox(); this.isFirefox = this.platformUtilsService.isFirefox();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe

View File

@@ -17,8 +17,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
const ComponentId = "SendComponent"; const ComponentId = "SendComponent";
@@ -43,7 +43,6 @@ export class SendGroupingsComponent extends BaseSendComponent {
ngZone: NgZone, ngZone: NgZone,
policyService: PolicyService, policyService: PolicyService,
searchService: SearchService, searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: BrowserStateService, private stateService: BrowserStateService,
private router: Router, private router: Router,
private syncService: SyncService, private syncService: SyncService,
@@ -74,7 +73,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
async ngOnInit() { async ngOnInit() {
// Determine Header details // Determine Header details
this.showLeftHeader = !( this.showLeftHeader = !(
this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
); );
// Clear state of Send Type Component // Clear state of Send Type Component
await this.stateService.setBrowserSendTypeComponentState(null); await this.stateService.setBrowserSendTypeComponentState(null);
@@ -97,7 +96,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
} }
if (!this.syncService.syncInProgress || restoredScopeState) { 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 // Load all sends if sync completed in background
@@ -172,7 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
private async saveState() { private async saveState() {
this.state = Object.assign(new BrowserSendComponentState(), { this.state = Object.assign(new BrowserSendComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window), scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText, searchText: this.searchText,
sends: this.sends, sends: this.sends,
typeCounts: this.typeCounts, typeCounts: this.typeCounts,

View File

@@ -18,8 +18,8 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { BrowserComponentState } from "../../../models/browserComponentState"; import { BrowserComponentState } from "../../../models/browserComponentState";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
const ComponentId = "SendTypeComponent"; const ComponentId = "SendTypeComponent";
@@ -42,7 +42,6 @@ export class SendTypeComponent extends BaseSendComponent {
ngZone: NgZone, ngZone: NgZone,
policyService: PolicyService, policyService: PolicyService,
searchService: SearchService, searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: BrowserStateService, private stateService: BrowserStateService,
private route: ActivatedRoute, private route: ActivatedRoute,
private location: Location, private location: Location,
@@ -102,7 +101,7 @@ export class SendTypeComponent extends BaseSendComponent {
// Restore state and remove reference // Restore state and remove reference
if (this.applySavedState && this.state != null) { 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); this.stateService.setBrowserSendTypeComponentState(null);
}); });
@@ -163,7 +162,7 @@ export class SendTypeComponent extends BaseSendComponent {
private async saveState() { private async saveState() {
this.state = { this.state = {
scrollY: this.popupUtils.getContentScrollY(window), scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText, searchText: this.searchText,
}; };
await this.stateService.setBrowserSendTypeComponentState(this.state); await this.stateService.setBrowserSendTypeComponentState(this.state);

View File

@@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; 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. * Service for determining whether to display file popout callout messages.
@@ -12,10 +12,7 @@ export class FilePopoutUtilsService {
/** /**
* Creates an instance of FilePopoutUtilsService. * Creates an instance of FilePopoutUtilsService.
*/ */
constructor( constructor(private platformUtilsService: PlatformUtilsService) {}
private platformUtilsService: PlatformUtilsService,
private popupUtilsService: PopupUtilsService
) {}
/** /**
* Determines whether to show any file popout callout message in the current browser. * Determines whether to show any file popout callout message in the current browser.
@@ -38,7 +35,7 @@ export class FilePopoutUtilsService {
showFirefoxFileWarning(win: Window): boolean { showFirefoxFileWarning(win: Window): boolean {
return ( return (
this.platformUtilsService.isFirefox() && 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. * @returns True if the extension is not in a popout; otherwise, false.
*/ */
showSafariFileWarning(win: Window): boolean { 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 ( return (
(this.isLinux(win) || this.isUnsupportedMac(win)) && (this.isLinux(win) || this.isUnsupportedMac(win)) &&
!this.platformUtilsService.isFirefox() && !this.platformUtilsService.isFirefox() &&
!(this.popupUtilsService.inSidebar(win) || this.popupUtilsService.inPopout(win)) !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win))
); );
} }

View File

@@ -29,7 +29,7 @@ import {
} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction"; } from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
import { BrowserApi } from "../../platform/browser/browser-api"; 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"; 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. * 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 { export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
constructor( constructor(private authService: AuthService) {}
private browserPopoutWindowService: BrowserPopoutWindowService,
private authService: AuthService
) {}
async newSession( async newSession(
fallbackSupported: boolean, fallbackSupported: boolean,
@@ -127,7 +124,6 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
abortController?: AbortController abortController?: AbortController
): Promise<Fido2UserInterfaceSession> { ): Promise<Fido2UserInterfaceSession> {
return await BrowserFido2UserInterfaceSession.create( return await BrowserFido2UserInterfaceSession.create(
this.browserPopoutWindowService,
this.authService, this.authService,
fallbackSupported, fallbackSupported,
tab, tab,
@@ -138,14 +134,12 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession { export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
static async create( static async create(
browserPopoutWindowService: BrowserPopoutWindowService,
authService: AuthService, authService: AuthService,
fallbackSupported: boolean, fallbackSupported: boolean,
tab: chrome.tabs.Tab, tab: chrome.tabs.Tab,
abortController?: AbortController abortController?: AbortController
): Promise<BrowserFido2UserInterfaceSession> { ): Promise<BrowserFido2UserInterfaceSession> {
return new BrowserFido2UserInterfaceSession( return new BrowserFido2UserInterfaceSession(
browserPopoutWindowService,
authService, authService,
fallbackSupported, fallbackSupported,
tab, tab,
@@ -183,7 +177,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private constructor( private constructor(
private readonly browserPopoutWindowService: BrowserPopoutWindowService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly fallbackSupported: boolean, private readonly fallbackSupported: boolean,
private readonly tab: chrome.tabs.Tab, private readonly tab: chrome.tabs.Tab,
@@ -304,7 +297,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
} }
async close() { async close() {
await this.browserPopoutWindowService.closeFido2Popout(); await closeFido2Popout(this.sessionId);
this.closed = true; this.closed = true;
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); 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, sessionId: this.sessionId,
senderTabId: this.tab.id,
fallbackSupported: this.fallbackSupported, fallbackSupported: this.fallbackSupported,
}); });

View File

@@ -35,6 +35,7 @@ import {
BrowserFido2Message, BrowserFido2Message,
BrowserFido2UserInterfaceSession, BrowserFido2UserInterfaceSession,
} from "../../../fido2/browser-fido2-user-interface.service"; } from "../../../fido2/browser-fido2-user-interface.service";
import { VaultPopoutType } from "../../utils/vault-popout-window";
interface ViewData { interface ViewData {
message: BrowserFido2Message; message: BrowserFido2Message;
@@ -280,6 +281,7 @@ export class Fido2Component implements OnInit, OnDestroy {
uilocation: "popout", uilocation: "popout",
senderTabId: this.senderTabId, senderTabId: this.senderTabId,
sessionId: this.sessionId, sessionId: this.sessionId,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
}, },
}); });
} }
@@ -299,6 +301,7 @@ export class Fido2Component implements OnInit, OnDestroy {
senderTabId: this.senderTabId, senderTabId: this.senderTabId,
sessionId: this.sessionId, sessionId: this.sessionId,
userVerification: data.userVerification, userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
}, },
}); });
} }

View File

@@ -24,11 +24,13 @@ import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault"; import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../platform/browser/browser-api"; 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 { import {
BrowserFido2UserInterfaceSession, BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$, fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service"; } from "../../../fido2/browser-fido2-user-interface.service";
import { VaultPopoutType, closeAddEditVaultItemPopout } from "../../utils/vault-popout-window";
@Component({ @Component({
selector: "app-vault-add-edit", selector: "app-vault-add-edit",
@@ -40,9 +42,7 @@ export class AddEditComponent extends BaseAddEditComponent {
showAttachments = true; showAttachments = true;
openAttachmentsInPopup: boolean; openAttachmentsInPopup: boolean;
showAutoFillOnPageLoadOptions: boolean; showAutoFillOnPageLoadOptions: boolean;
senderTabId?: number; private singleActionKey: string;
uilocation?: "popout" | "popup" | "sidebar" | "tab";
inPopout = false;
private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData$ = fido2PopoutSessionData$();
@@ -60,7 +60,7 @@ export class AddEditComponent extends BaseAddEditComponent {
private location: Location, private location: Location,
eventCollectionService: EventCollectionService, eventCollectionService: EventCollectionService,
policyService: PolicyService, policyService: PolicyService,
private popupUtilsService: PopupUtilsService, private popupCloseWarningService: PopupCloseWarningService,
organizationService: OrganizationService, organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
logService: LogService, logService: LogService,
@@ -91,9 +91,6 @@ export class AddEditComponent extends BaseAddEditComponent {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => { this.route.queryParams.pipe(first()).subscribe(async (params) => {
this.senderTabId = parseInt(params?.senderTabId, 10) || undefined;
this.uilocation = params?.uilocation;
if (params.cipherId) { if (params.cipherId) {
this.cipherId = params.cipherId; this.cipherId = params.cipherId;
} }
@@ -119,18 +116,16 @@ export class AddEditComponent extends BaseAddEditComponent {
if (params.selectedVault) { if (params.selectedVault) {
this.organizationId = params.selectedVault; this.organizationId = params.selectedVault;
} }
if (params.singleActionKey) {
this.singleActionKey = params.singleActionKey;
}
await this.load(); await this.load();
if (!this.editMode || this.cloneMode) { if (!this.editMode || this.cloneMode) {
if ( if (params.name && (this.cipher.name == null || this.cipher.name === "")) {
!this.popupUtilsService.inPopout(window) &&
params.name &&
(this.cipher.name == null || this.cipher.name === "")
) {
this.cipher.name = params.name; this.cipher.name = params.name;
} }
if ( if (
!this.popupUtilsService.inPopout(window) &&
params.uri && params.uri &&
(this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].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) { if (!this.editMode) {
const tabs = await BrowserApi.tabsQuery({ windowType: "normal" }); const tabs = await BrowserApi.tabsQuery({ windowType: "normal" });
this.currentUris = this.currentUris =
@@ -153,8 +146,8 @@ export class AddEditComponent extends BaseAddEditComponent {
this.setFocus(); this.setFocus();
if (this.popupUtilsService.inTab(window)) { if (BrowserPopupUtils.inPopout(window)) {
this.popupUtilsService.enableCloseTabWarning(); this.popupCloseWarningService.enable();
} }
} }
@@ -167,12 +160,10 @@ export class AddEditComponent extends BaseAddEditComponent {
async submit(): Promise<boolean> { async submit(): Promise<boolean> {
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$); const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
// Would be refactored after rework is done on the windows popout service
const { isFido2Session, sessionId, userVerification } = fido2SessionData; const { isFido2Session, sessionId, userVerification } = fido2SessionData;
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
if ( if (
this.inPopout && inFido2PopoutWindow &&
isFido2Session &&
!(await this.handleFido2UserVerification(sessionId, userVerification)) !(await this.handleFido2UserVerification(sessionId, userVerification))
) { ) {
return false; return false;
@@ -183,7 +174,11 @@ export class AddEditComponent extends BaseAddEditComponent {
return false; return false;
} }
if (this.inPopout && isFido2Session) { if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.disable();
}
if (inFido2PopoutWindow) {
BrowserFido2UserInterfaceSession.confirmNewCredentialResponse( BrowserFido2UserInterfaceSession.confirmNewCredentialResponse(
sessionId, sessionId,
this.cipher.id, this.cipher.id,
@@ -192,14 +187,8 @@ export class AddEditComponent extends BaseAddEditComponent {
return true; return true;
} }
if (this.popupUtilsService.inTab(window)) { if (this.inAddEditPopoutWindow()) {
this.popupUtilsService.disableCloseTabWarning(); await closeAddEditVaultItemPopout(1000);
this.messagingService.send("closeTab", { delay: 1000 });
return true;
}
if (this.senderTabId && this.inPopout) {
setTimeout(() => this.close(), 1000);
return true; return true;
} }
@@ -219,7 +208,7 @@ export class AddEditComponent extends BaseAddEditComponent {
.createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipher.id } }) .createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipher.id } })
.toString(); .toString();
const currentBaseUrl = window.location.href.replace(this.router.url, ""); const currentBaseUrl = window.location.href.replace(this.router.url, "");
this.popupUtilsService.popOut(window, currentBaseUrl + destinationUrl); BrowserPopupUtils.openCurrentPagePopout(window, currentBaseUrl + destinationUrl);
} else { } else {
this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipher.id } }); this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipher.id } });
} }
@@ -235,34 +224,21 @@ export class AddEditComponent extends BaseAddEditComponent {
async cancel() { async cancel() {
super.cancel(); super.cancel();
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$); const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) { if (BrowserPopupUtils.inPopout(window) && sessionData.isFido2Session) {
this.popupCloseWarningService.disable();
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId); BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
return; return;
} }
if (this.senderTabId && this.inPopout) { if (this.inAddEditPopoutWindow()) {
this.close(); closeAddEditVaultItemPopout();
return;
}
if (this.popupUtilsService.inTab(window)) {
this.messagingService.send("closeTab");
return; return;
} }
this.location.back(); this.location.back();
} }
// Used for closing single-action views
close() {
BrowserApi.focusTab(this.senderTabId);
window.close();
return;
}
async generateUsername(): Promise<boolean> { async generateUsername(): Promise<boolean> {
const confirmed = await super.generateUsername(); const confirmed = await super.generateUsername();
if (confirmed) { if (confirmed) {
@@ -356,4 +332,11 @@ export class AddEditComponent extends BaseAddEditComponent {
this.i18nService.t("autofillOnPageLoadSetToDefault") this.i18nService.t("autofillOnPageLoadSetToDefault")
); );
} }
private inAddEditPopoutWindow() {
return BrowserPopupUtils.inSingleActionPopout(
window,
this.singleActionKey || VaultPopoutType.addEditVaultItem
);
}
} }

View File

@@ -19,7 +19,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service"; import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../../../../platform/browser/browser-api"; 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"; import { VaultFilterService } from "../../../services/vault-filter.service";
const BroadcasterSubscriptionId = "CurrentTabComponent"; const BroadcasterSubscriptionId = "CurrentTabComponent";
@@ -55,7 +55,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
constructor( constructor(
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private cipherService: CipherService, private cipherService: CipherService,
private popupUtilsService: PopupUtilsService,
private autofillService: AutofillService, private autofillService: AutofillService,
private i18nService: I18nService, private i18nService: I18nService,
private router: Router, private router: Router,
@@ -72,7 +71,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari(); this.searchTypeSearch = !this.platformUtilsService.isSafari();
this.inSidebar = this.popupUtilsService.inSidebar(window); this.inSidebar = BrowserPopupUtils.inSidebar(window);
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => { this.ngZone.run(async () => {
@@ -185,7 +184,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
if (this.totpCode != null) { if (this.totpCode != null) {
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window }); this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });
} }
if (this.popupUtilsService.inPopup(window)) { if (BrowserPopupUtils.inPopup(window)) {
if (!closePopupDelay) { if (!closePopupDelay) {
if (this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari()) { if (this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari()) {
BrowserApi.closePopup(window); BrowserApi.closePopup(window);

View File

@@ -19,8 +19,8 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState"; import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState";
import { BrowserApi } from "../../../../platform/browser/browser-api"; 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 { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import { VaultFilterService } from "../../../services/vault-filter.service"; import { VaultFilterService } from "../../../services/vault-filter.service";
const ComponentId = "VaultComponent"; const ComponentId = "VaultComponent";
@@ -80,7 +80,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private route: ActivatedRoute, private route: ActivatedRoute,
private popupUtils: PopupUtilsService,
private syncService: SyncService, private syncService: SyncService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private searchService: SearchService, private searchService: SearchService,
@@ -94,7 +93,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari(); this.searchTypeSearch = !this.platformUtilsService.isSafari();
this.showLeftHeader = !( this.showLeftHeader = !(
this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
); );
await this.browserStateService.setBrowserVaultItemsComponentState(null); await this.browserStateService.setBrowserVaultItemsComponentState(null);
@@ -136,7 +135,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
} }
if (!this.syncService.syncInProgress || restoredScopeState) { 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; this.preventSelected = true;
await this.cipherService.updateLastLaunchedDate(cipher.id); await this.cipherService.updateLastLaunchedDate(cipher.id);
BrowserApi.createNewTab(cipher.login.launchUri); BrowserApi.createNewTab(cipher.login.launchUri);
if (this.popupUtils.inPopup(window)) { if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window); BrowserApi.closePopup(window);
} }
} }
@@ -376,7 +375,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private async saveState() { private async saveState() {
this.state = Object.assign(new BrowserGroupingsComponentState(), { this.state = Object.assign(new BrowserGroupingsComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window), scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText, searchText: this.searchText,
favoriteCiphers: this.favoriteCiphers, favoriteCiphers: this.favoriteCiphers,
noFolderCiphers: this.noFolderCiphers, noFolderCiphers: this.noFolderCiphers,

View File

@@ -13,7 +13,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.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 { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.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 { BrowserComponentState } from "../../../../models/browserComponentState";
import { BrowserApi } from "../../../../platform/browser/browser-api"; 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 { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import { VaultFilterService } from "../../../services/vault-filter.service"; import { VaultFilterService } from "../../../services/vault-filter.service";
const ComponentId = "VaultItemsComponent"; const ComponentId = "VaultItemsComponent";
@@ -61,9 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private stateService: BrowserStateService, private stateService: BrowserStateService,
private popupUtils: PopupUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private folderService: FolderService,
private collectionService: CollectionService, private collectionService: CollectionService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
cipherService: CipherService, cipherService: CipherService,
@@ -155,11 +152,10 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
} }
if (this.applySavedState && this.state != null) { if (this.applySavedState && this.state != null) {
window.setTimeout( BrowserPopupUtils.setContentScrollY(window, this.state.scrollY, {
() => delay: 0,
this.popupUtils.setContentScrollY(window, this.state.scrollY, this.scrollingContainer), containerSelector: this.scrollingContainer,
0 });
);
} }
await this.stateService.setBrowserVaultItemsComponentState(null); await this.stateService.setBrowserVaultItemsComponentState(null);
}); });
@@ -219,7 +215,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
this.preventSelected = true; this.preventSelected = true;
await this.cipherService.updateLastLaunchedDate(cipher.id); await this.cipherService.updateLastLaunchedDate(cipher.id);
BrowserApi.createNewTab(cipher.login.launchUri); BrowserApi.createNewTab(cipher.login.launchUri);
if (this.popupUtils.inPopup(window)) { if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window); BrowserApi.closePopup(window);
} }
} }
@@ -288,7 +284,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
private async saveState() { private async saveState() {
this.state = { this.state = {
scrollY: this.popupUtils.getContentScrollY(window, this.scrollingContainer), scrollY: BrowserPopupUtils.getContentScrollY(window, this.scrollingContainer),
searchText: this.searchText, searchText: this.searchText,
}; };
await this.stateService.setBrowserVaultItemsComponentState(this.state); await this.stateService.setBrowserVaultItemsComponentState(this.state);

View File

@@ -28,7 +28,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service"; import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../../../../platform/browser/browser-api"; import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { import {
BrowserFido2UserInterfaceSession, BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$, fido2PopoutSessionData$,
@@ -84,7 +84,6 @@ export class ViewComponent extends BaseViewComponent {
eventCollectionService: EventCollectionService, eventCollectionService: EventCollectionService,
private autofillService: AutofillService, private autofillService: AutofillService,
private messagingService: MessagingService, private messagingService: MessagingService,
private popupUtilsService: PopupUtilsService,
apiService: ApiService, apiService: ApiService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
logService: LogService, logService: LogService,
@@ -121,7 +120,7 @@ export class ViewComponent extends BaseViewComponent {
this.uilocation = value?.uilocation; 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 // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => { this.route.queryParams.pipe(first()).subscribe(async (params) => {

View File

@@ -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<chrome.windows.Window>({ 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<chrome.tabs.Tab>({ 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<chrome.tabs.Tab>({ 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<chrome.tabs.Tab>({ 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<chrome.tabs.Tab>({
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}`
);
});
});
});

View File

@@ -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<chrome.windows.Window["id"]> {
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<void> {
await BrowserPopupUtils.closeSingleActionPopout(`${VaultPopoutType.fido2Popout}_${sessionId}`);
}
export {
VaultPopoutType,
openVaultItemPasswordRepromptPopout,
openAddEditVaultItemPopout,
closeAddEditVaultItemPopout,
openFido2Popout,
closeFido2Popout,
};

View File

@@ -23,6 +23,7 @@ const runtime = {
}, },
sendMessage: jest.fn(), sendMessage: jest.fn(),
getManifest: jest.fn(), getManifest: jest.fn(),
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
}; };
const contextMenus = { const contextMenus = {
@@ -37,12 +38,21 @@ const i18n = {
const tabs = { const tabs = {
executeScript: jest.fn(), executeScript: jest.fn(),
sendMessage: jest.fn(), sendMessage: jest.fn(),
query: jest.fn(),
}; };
const scripting = { const scripting = {
executeScript: jest.fn(), executeScript: jest.fn(),
}; };
const windows = {
create: jest.fn(),
get: jest.fn(),
getCurrent: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
// set chrome // set chrome
global.chrome = { global.chrome = {
i18n, i18n,
@@ -51,4 +61,5 @@ global.chrome = {
contextMenus, contextMenus,
tabs, tabs,
scripting, scripting,
windows,
} as any; } as any;