diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b5d5afb27c4..63dd227e52b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -630,6 +630,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, diff --git a/apps/browser/src/autofill/background/context-menus.background.ts b/apps/browser/src/autofill/background/context-menus.background.ts index 681f86cdf67..bc26353cbd9 100644 --- a/apps/browser/src/autofill/background/context-menus.background.ts +++ b/apps/browser/src/autofill/background/context-menus.background.ts @@ -30,6 +30,7 @@ export default class ContextMenusBackground { msg.data.commandToRetry.msg.data, msg.data.commandToRetry.sender.tab ); + await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); } } ); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 07b565e2608..1cb006fa3a2 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -12,6 +12,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import AddUnlockVaultQueueMessage from "../../background/models/add-unlock-vault-queue-message"; import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage"; import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage"; import AddLoginRuntimeMessage from "../../background/models/addLoginRuntimeMessage"; @@ -23,7 +24,11 @@ import { BrowserStateService } from "../../platform/services/abstractions/browse import { AutofillService } from "../services/abstractions/autofill.service"; export default class NotificationBackground { - private notificationQueue: (AddLoginQueueMessage | AddChangePasswordQueueMessage)[] = []; + private notificationQueue: ( + | AddLoginQueueMessage + | AddChangePasswordQueueMessage + | AddUnlockVaultQueueMessage + )[] = []; constructor( private autofillService: AutofillService, @@ -53,10 +58,7 @@ export default class NotificationBackground { async processMessage(msg: any, sender: chrome.runtime.MessageSender) { switch (msg.command) { case "unlockCompleted": - if (msg.data.target !== "notification.background") { - return; - } - await this.processMessage(msg.data.commandToRetry.msg, msg.data.commandToRetry.sender); + await this.handleUnlockCompleted(msg.data, sender); break; case "bgGetDataForTab": await this.getDataForTab(sender.tab, msg.responseCommand); @@ -82,7 +84,9 @@ export default class NotificationBackground { if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { const retryMessage: LockedVaultPendingNotificationsItem = { commandToRetry: { - msg: msg, + msg: { + command: msg, + }, sender: sender, }, target: "notification.background", @@ -114,6 +118,9 @@ export default class NotificationBackground { break; } break; + case "promptForLogin": + await this.unlockVault(sender.tab); + break; default: break; } @@ -181,6 +188,14 @@ export default class NotificationBackground { webVaultURL: await this.environmentService.getWebVaultUrl(), }, }); + } else if (this.notificationQueue[i].type === NotificationQueueMessageType.UnlockVault) { + BrowserApi.tabSendMessageData(tab, "openNotificationBar", { + type: "unlock", + typeData: { + isVaultLocked: this.notificationQueue[i].wasVaultLocked, + theme: await this.getCurrentTheme(), + }, + }); } break; } @@ -305,6 +320,20 @@ export default class NotificationBackground { } } + private async unlockVault(tab: chrome.tabs.Tab) { + const currentAuthStatus = await this.authService.getAuthStatus(); + if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) { + return; + } + + const loginDomain = Utils.getDomain(tab.url); + if (!loginDomain) { + return; + } + + this.pushUnlockVaultToQueue(loginDomain, tab); + } + private async pushChangePasswordToQueue( cipherId: string, loginDomain: string, @@ -327,6 +356,20 @@ export default class NotificationBackground { await this.checkNotificationQueue(tab); } + private async pushUnlockVaultToQueue(loginDomain: string, tab: chrome.tabs.Tab) { + this.removeTabFromNotificationQueue(tab); + const message: AddUnlockVaultQueueMessage = { + type: NotificationQueueMessageType.UnlockVault, + domain: loginDomain, + tabId: tab.id, + expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds + wasVaultLocked: true, + }; + this.notificationQueue.push(message); + await this.checkNotificationQueue(tab); + this.removeTabFromNotificationQueue(tab); + } + private async saveOrUpdateCredentials(tab: chrome.tabs.Tab, edit: boolean, folderId?: string) { for (let i = this.notificationQueue.length - 1; i >= 0; i--) { const queueMessage = this.notificationQueue[i]; @@ -463,4 +506,22 @@ export default class NotificationBackground { this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) ); } + + private async handleUnlockCompleted( + messageData: LockedVaultPendingNotificationsItem, + sender: chrome.runtime.MessageSender + ): Promise { + if (messageData.commandToRetry.msg.command === "autofill_login") { + await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); + } + + if (messageData.target !== "notification.background") { + return; + } + + await this.processMessage( + messageData.commandToRetry.msg.command, + messageData.commandToRetry.sender + ); + } } diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index deec7fd512c..a6be58de70a 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -51,4 +51,13 @@ + + diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 728ae4e1287..dcc4ce010f6 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -28,6 +28,8 @@ function load() { notificationEdit: chrome.i18n.getMessage("edit"), notificationChangeSave: chrome.i18n.getMessage("notificationChangeSave"), notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"), + notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), + notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), }; const logoLink = document.getElementById("logo-link") as HTMLAnchorElement; @@ -72,6 +74,13 @@ function load() { changeTemplate.content.getElementById("change-text").textContent = i18n.notificationChangeDesc; + const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement; + + const unlockButton = unlockTemplate.content.getElementById("unlock-vault"); + unlockButton.textContent = i18n.notificationUnlock; + + unlockTemplate.content.getElementById("unlock-text").textContent = i18n.notificationUnlockDesc; + // i18n for body content const closeButton = document.getElementById("close-button"); closeButton.title = i18n.close; @@ -80,6 +89,8 @@ function load() { handleTypeAdd(); } else if (getQueryVariable("type") === "change") { handleTypeChange(); + } else if (getQueryVariable("type") === "unlock") { + handleTypeUnlock(); } closeButton.addEventListener("click", (e) => { @@ -172,6 +183,17 @@ function handleTypeChange() { }); } +function handleTypeUnlock() { + setContent(document.getElementById("template-unlock") as HTMLTemplateElement); + + const unlockButton = document.getElementById("unlock-vault"); + unlockButton.addEventListener("click", (e) => { + sendPlatformMessage({ + command: "bgReopenPromptForLogin", + }); + }); +} + function setContent(template: HTMLTemplateElement) { const content = document.getElementById("content"); while (content.firstChild) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4e310354699..59a1b444e09 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -114,6 +114,7 @@ import { Account } from "../models/account"; import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; +import BrowserPopoutWindowService from "../platform/popup/browser-popout-window.service"; import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; @@ -195,6 +196,7 @@ export default class MainBackground { cipherContextMenuHandler: CipherContextMenuHandler; configService: ConfigServiceAbstraction; configApiService: ConfigApiServiceAbstraction; + browserPopoutWindowService: BrowserPopoutWindowService; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -512,6 +514,7 @@ export default class MainBackground { this.authService, this.environmentService ); + this.browserPopoutWindowService = new BrowserPopoutWindowService(); const systemUtilsServiceReloadCallback = () => { const forceWindowReload = @@ -543,7 +546,8 @@ export default class MainBackground { this.environmentService, this.messagingService, this.logService, - this.configService + this.configService, + this.browserPopoutWindowService ); this.nativeMessagingBackground = new NativeMessagingBackground( this.cryptoService, diff --git a/apps/browser/src/background/models/add-unlock-vault-queue-message.ts b/apps/browser/src/background/models/add-unlock-vault-queue-message.ts new file mode 100644 index 00000000000..9ddde271008 --- /dev/null +++ b/apps/browser/src/background/models/add-unlock-vault-queue-message.ts @@ -0,0 +1,6 @@ +import NotificationQueueMessage from "./notificationQueueMessage"; +import { NotificationQueueMessageType } from "./notificationQueueMessageType"; + +export default class AddUnlockVaultQueueMessage extends NotificationQueueMessage { + type: NotificationQueueMessageType.UnlockVault; +} diff --git a/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts b/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts index ec697b16994..53f8405cd50 100644 --- a/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts +++ b/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts @@ -1,6 +1,9 @@ export default class LockedVaultPendingNotificationsItem { commandToRetry: { - msg: any; + msg: { + command: string; + data?: any; + }; sender: chrome.runtime.MessageSender; }; target: string; diff --git a/apps/browser/src/background/models/notificationQueueMessageType.ts b/apps/browser/src/background/models/notificationQueueMessageType.ts index f5e4115c4f5..2ce1a1840d8 100644 --- a/apps/browser/src/background/models/notificationQueueMessageType.ts +++ b/apps/browser/src/background/models/notificationQueueMessageType.ts @@ -1,4 +1,5 @@ export enum NotificationQueueMessageType { AddLogin = 0, ChangePassword = 1, + UnlockVault = 2, } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 81f7376a94d..2e9fc934882 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -8,6 +8,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../platform/browser/browser-api"; +import { BrowserPopoutWindowService } from "../platform/popup/abstractions/browser-popout-window.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; @@ -30,7 +31,8 @@ export default class RuntimeBackground { private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, 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 chrome.runtime.onInstalled.addListener((details: any) => { @@ -66,7 +68,7 @@ export default class RuntimeBackground { if (this.lockedVaultPendingNotifications?.length > 0) { item = this.lockedVaultPendingNotifications.pop(); - BrowserApi.closeBitwardenExtensionTab(); + await this.browserPopoutWindowService.closeLoginPrompt(); } await this.main.refreshBadge(); @@ -105,7 +107,8 @@ export default class RuntimeBackground { await this.main.openPopup(); break; case "promptForLogin": - BrowserApi.openBitwardenExtensionTab("popup/index.html", true); + case "bgReopenPromptForLogin": + await this.browserPopoutWindowService.openLoginPrompt(sender.tab?.windowId); break; case "openAddEditCipher": { const addEditCipherUrl = diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 7c646e5c7e6..243971dbfc0 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -17,6 +17,24 @@ export class BrowserApi { return chrome.runtime.getManifest().manifest_version; } + static getWindow(windowId?: number): Promise | void { + if (!windowId) { + return; + } + + return new Promise((resolve) => + chrome.windows.get(windowId, { populate: true }, (window) => resolve(window)) + ); + } + + static async createWindow(options: chrome.windows.CreateData): Promise { + return new Promise((resolve) => + chrome.windows.create(options, (window) => { + resolve(window); + }) + ); + } + static async getTabFromCurrentWindowId(): Promise | null { return await BrowserApi.tabsQueryFirst({ active: true, @@ -105,6 +123,10 @@ export class BrowserApi { chrome.tabs.sendMessage(tabId, message, options, responseCallback); } + static async removeTab(tabId: number) { + await chrome.tabs.remove(tabId); + } + static async getPrivateModeWindows(): Promise { return (await browser.windows.getAll()).filter((win) => win.incognito); } @@ -165,7 +187,7 @@ export class BrowserApi { } const tabToClose = tabs[tabs.length - 1]; - chrome.tabs.remove(tabToClose.id); + BrowserApi.removeTab(tabToClose.id); } private static registeredMessageListeners: any[] = []; diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts new file mode 100644 index 00000000000..ca22e369d80 --- /dev/null +++ b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts @@ -0,0 +1,6 @@ +interface BrowserPopoutWindowService { + openLoginPrompt(senderWindowId: number): Promise; + closeLoginPrompt(): Promise; +} + +export { BrowserPopoutWindowService }; diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts new file mode 100644 index 00000000000..bfec3e690ba --- /dev/null +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -0,0 +1,64 @@ +import { BrowserApi } from "../browser/browser-api"; + +import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from "./abstractions/browser-popout-window.service"; + +class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { + private singleActionPopoutTabIds: Record = {}; + private defaultPopoutWindowOptions: chrome.windows.CreateData = { + type: "normal", + focused: true, + width: 500, + height: 800, + }; + + async openLoginPrompt(senderWindowId: number) { + await this.closeLoginPrompt(); + await this.openPopoutWindow( + senderWindowId, + "popup/index.html?uilocation=popout", + "loginPrompt" + ); + } + + async closeLoginPrompt() { + await this.closeSingleActionPopout("loginPrompt"); + } + + private async openPopoutWindow( + senderWindowId: number, + popupWindowURL: string, + singleActionPopoutKey: string + ) { + const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId)); + const url = chrome.extension.getURL(popupWindowURL); + const offsetRight = 15; + const offsetTop = 90; + const popupWidth = this.defaultPopoutWindowOptions.width; + const windowOptions = senderWindow + ? { + ...this.defaultPopoutWindowOptions, + url, + left: senderWindow.left + senderWindow.width - popupWidth - offsetRight, + top: senderWindow.top + offsetTop, + } + : { ...this.defaultPopoutWindowOptions, url }; + + const popupWindow = await BrowserApi.createWindow(windowOptions); + + if (!singleActionPopoutKey) { + return; + } + this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id; + } + + private async closeSingleActionPopout(popoutKey: string) { + const tabId = this.singleActionPopoutTabIds[popoutKey]; + if (!tabId) { + return; + } + await BrowserApi.removeTab(tabId); + this.singleActionPopoutTabIds[popoutKey] = null; + } +} + +export default BrowserPopoutWindowService;