1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

Merge branch 'PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build' of github.com:bitwarden/clients into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

merge main
This commit is contained in:
John Harrington
2025-11-25 15:26:55 -07:00
89 changed files with 1127 additions and 404 deletions

View File

@@ -585,6 +585,9 @@
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
},
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
},
"edit": {
"message": "Edit"
},
@@ -594,6 +597,9 @@
"viewAll": {
"message": "View all"
},
"showAll": {
"message": "Show all"
},
"viewLess": {
"message": "View less"
},

View File

@@ -1344,7 +1344,7 @@ export default class NotificationBackground {
return;
}
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const extensionUrl = BrowserApi.getRuntimeURL("popup/index.html");
const unlockPopoutTabs = (await BrowserApi.tabsQuery({ url: `${extensionUrl}*` })).filter(
(tab) => tab.url?.includes(`singleActionPopout=${AuthPopoutType.unlockExtension}`),
);

View File

@@ -2949,13 +2949,13 @@ export class OverlayBackground implements OverlayBackgroundInterface {
(await this.checkFocusedFieldHasValue(port.sender.tab)) &&
(await this.shouldShowSaveLoginInlineMenuList(port.sender.tab));
const iframeUrl = chrome.runtime.getURL(
const iframeUrl = BrowserApi.getRuntimeURL(
`overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`,
);
const styleSheetUrl = chrome.runtime.getURL(
const styleSheetUrl = BrowserApi.getRuntimeURL(
`overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`,
);
const extensionOrigin = new URL(iframeUrl).origin;
const extensionOrigin = iframeUrl ? new URL(iframeUrl).origin : null;
this.postMessageToPort(port, {
command: `initAutofillInlineMenu${isInlineMenuListPort ? "List" : "Button"}`,

View File

@@ -56,7 +56,11 @@ describe("ContentMessageHandler", () => {
});
it("sends an authResult message", () => {
postWindowMessage({ command: "authResult", lastpass: true, code: "code", state: "state" });
postWindowMessage(
{ command: "authResult", lastpass: true, code: "code", state: "state" },
"https://localhost/",
window,
);
expect(sendMessageSpy).toHaveBeenCalledWith({
command: "authResult",
@@ -68,7 +72,11 @@ describe("ContentMessageHandler", () => {
});
it("sends a webAuthnResult message", () => {
postWindowMessage({ command: "webAuthnResult", data: "data", remember: true });
postWindowMessage(
{ command: "webAuthnResult", data: "data", remember: true },
"https://localhost/",
window,
);
expect(sendMessageSpy).toHaveBeenCalledWith({
command: "webAuthnResult",
@@ -82,7 +90,7 @@ describe("ContentMessageHandler", () => {
const mockCode = "mockCode";
const command = "duoResult";
postWindowMessage({ command: command, code: mockCode });
postWindowMessage({ command: command, code: mockCode }, "https://localhost/", window);
expect(sendMessageSpy).toHaveBeenCalledWith({
command: command,

View File

@@ -51,6 +51,7 @@ type NotificationBarWindowMessage = {
};
error?: string;
initData?: NotificationBarIframeInitData;
parentOrigin?: string;
};
type NotificationBarWindowMessageHandlers = {

View File

@@ -0,0 +1,121 @@
import { mock } from "jest-mock-extended";
import { postWindowMessage } from "../spec/testing-utils";
import { NotificationBarWindowMessage } from "./abstractions/notification-bar";
import "./bar";
jest.mock("lit", () => ({ render: jest.fn() }));
jest.mock("@lit-labs/signals", () => ({
signal: jest.fn((testValue) => ({ get: (): typeof testValue => testValue })),
}));
jest.mock("../content/components/notification/container", () => ({
NotificationContainer: jest.fn(),
}));
describe("NotificationBar iframe handleWindowMessage security", () => {
const trustedOrigin = "http://localhost";
const maliciousOrigin = "https://malicious.com";
const createMessage = (
overrides: Partial<NotificationBarWindowMessage> = {},
): NotificationBarWindowMessage => ({
command: "initNotificationBar",
...overrides,
});
beforeEach(() => {
Object.defineProperty(globalThis, "location", {
value: { search: `?parentOrigin=${encodeURIComponent(trustedOrigin)}` },
writable: true,
configurable: true,
});
Object.defineProperty(globalThis, "parent", {
value: mock<Window>(),
writable: true,
configurable: true,
});
globalThis.dispatchEvent(new Event("load"));
});
afterEach(() => {
jest.clearAllMocks();
});
it.each([
{
description: "not from parent window",
message: () => createMessage(),
origin: trustedOrigin,
source: () => mock<Window>(),
},
{
description: "with mismatched origin",
message: () => createMessage(),
origin: maliciousOrigin,
source: () => globalThis.parent,
},
{
description: "without command field",
message: () => ({}),
origin: trustedOrigin,
source: () => globalThis.parent,
},
{
description: "initNotificationBar with mismatched parentOrigin",
message: () => createMessage({ parentOrigin: maliciousOrigin }),
origin: trustedOrigin,
source: () => globalThis.parent,
},
{
description: "when windowMessageOrigin is not set",
message: () => createMessage(),
origin: "different-origin",
source: () => globalThis.parent,
resetOrigin: true,
},
{
description: "with null source",
message: () => createMessage(),
origin: trustedOrigin,
source: (): null => null,
},
{
description: "with unknown command",
message: () => createMessage({ command: "unknownCommand" }),
origin: trustedOrigin,
source: () => globalThis.parent,
},
])("should reject messages $description", ({ message, origin, source, resetOrigin }) => {
if (resetOrigin) {
Object.defineProperty(globalThis, "location", {
value: { search: "" },
writable: true,
configurable: true,
});
}
const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation();
postWindowMessage(message(), origin, source());
expect(spy).not.toHaveBeenCalled();
});
it("should accept and handle valid trusted messages", () => {
const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation();
spy.mockClear();
const validMessage = createMessage({
parentOrigin: trustedOrigin,
initData: {
type: "change",
isVaultLocked: false,
removeIndividualVault: false,
importType: null,
launchTimestamp: Date.now(),
},
});
postWindowMessage(validMessage, trustedOrigin, globalThis.parent);
expect(validMessage.command).toBe("initNotificationBar");
expect(validMessage.parentOrigin).toBe(trustedOrigin);
expect(validMessage.initData).toBeDefined();
});
});

View File

@@ -24,6 +24,13 @@ import {
let notificationBarIframeInitData: NotificationBarIframeInitData = {};
let windowMessageOrigin: string;
const urlParams = new URLSearchParams(globalThis.location.search);
const trustedParentOrigin = urlParams.get("parentOrigin");
if (trustedParentOrigin) {
windowMessageOrigin = trustedParentOrigin;
}
const notificationBarWindowMessageHandlers: NotificationBarWindowMessageHandlers = {
initNotificationBar: ({ message }) => initNotificationBar(message),
saveCipherAttemptCompleted: ({ message }) => handleSaveCipherConfirmation(message),
@@ -395,15 +402,27 @@ function setupWindowMessageListener() {
}
function handleWindowMessage(event: MessageEvent) {
if (!windowMessageOrigin) {
windowMessageOrigin = event.origin;
}
if (event.origin !== windowMessageOrigin) {
if (event?.source !== globalThis.parent) {
return;
}
const message = event.data as NotificationBarWindowMessage;
if (!message?.command) {
return;
}
if (!windowMessageOrigin || event.origin !== windowMessageOrigin) {
return;
}
if (
message.command === "initNotificationBar" &&
message.parentOrigin &&
message.parentOrigin !== event.origin
) {
return;
}
const handler = notificationBarWindowMessageHandlers[message.command];
if (!handler) {
return;
@@ -431,5 +450,8 @@ function getResolvedTheme(theme: Theme) {
}
function postMessageToParent(message: NotificationBarWindowMessage) {
globalThis.parent.postMessage(message, windowMessageOrigin || "*");
if (!windowMessageOrigin) {
return;
}
globalThis.parent.postMessage(message, windowMessageOrigin);
}

View File

@@ -10,6 +10,7 @@ export type InitAutofillInlineMenuButtonMessage = UpdateAuthStatusMessage & {
styleSheetUrl: string;
translations: Record<string, string>;
portKey: string;
token: string;
};
export type AutofillInlineMenuButtonWindowMessageHandlers = {

View File

@@ -5,7 +5,7 @@ import { InlineMenuCipherData } from "../../../background/abstractions/overlay.b
export type AutofillInlineMenuContainerMessage = {
command: string;
portKey: string;
token?: string;
token: string;
};
export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & {

View File

@@ -27,6 +27,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage &
showInlineMenuAccountCreation?: boolean;
showPasskeysLabels?: boolean;
portKey: string;
token: string;
generatedPassword?: string;
showSaveLoginMenu?: boolean;
};

View File

@@ -191,7 +191,7 @@ describe("AutofillInlineMenuIframeService", () => {
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
).toHaveBeenCalledWith(message, "*");
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
});
it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => {
@@ -217,7 +217,7 @@ describe("AutofillInlineMenuIframeService", () => {
expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey);
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
).toHaveBeenCalledWith(message, "*");
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
});
});
@@ -242,7 +242,7 @@ describe("AutofillInlineMenuIframeService", () => {
expect(updateElementStylesSpy).not.toHaveBeenCalled();
expect(
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
).toHaveBeenCalledWith(message, "*");
).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]);
});
it("sets a light theme based on the user's system preferences", () => {
@@ -262,7 +262,7 @@ describe("AutofillInlineMenuIframeService", () => {
command: "initAutofillInlineMenuList",
theme: ThemeType.Light,
},
"*",
autofillInlineMenuIframeService["extensionOrigin"],
);
});
@@ -283,7 +283,7 @@ describe("AutofillInlineMenuIframeService", () => {
command: "initAutofillInlineMenuList",
theme: ThemeType.Dark,
},
"*",
autofillInlineMenuIframeService["extensionOrigin"],
);
});
@@ -387,7 +387,7 @@ describe("AutofillInlineMenuIframeService", () => {
command: "updateAutofillInlineMenuColorScheme",
colorScheme: "normal",
},
"*",
autofillInlineMenuIframeService["extensionOrigin"],
);
});

View File

@@ -3,6 +3,7 @@
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { sendExtensionMessage, setElementStyles } from "../../../utils";
import {
BackgroundPortMessageHandlers,
@@ -15,6 +16,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
private readonly sendExtensionMessage = sendExtensionMessage;
private port: chrome.runtime.Port | null = null;
private portKey: string;
private readonly extensionOrigin: string;
private iframeMutationObserver: MutationObserver;
private iframe: HTMLIFrameElement;
private ariaAlertElement: HTMLDivElement;
@@ -69,6 +71,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
private iframeTitle: string,
private ariaAlert?: string,
) {
this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1);
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
}
@@ -81,7 +84,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
* that is declared.
*/
initMenuIframe() {
this.defaultIframeAttributes.src = chrome.runtime.getURL("overlay/menu.html");
this.defaultIframeAttributes.src = BrowserApi.getRuntimeURL("overlay/menu.html");
this.defaultIframeAttributes.title = this.iframeTitle;
this.iframe = globalThis.document.createElement("iframe");
@@ -259,7 +262,10 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
}
private postMessageToIFrame(message: any) {
this.iframe.contentWindow?.postMessage({ portKey: this.portKey, ...message }, "*");
this.iframe.contentWindow?.postMessage(
{ portKey: this.portKey, ...message },
this.extensionOrigin,
);
}
/**

View File

@@ -1,5 +1,6 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import { createInitAutofillInlineMenuButtonMessageMock } from "../../../../spec/autofill-mocks";
import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils";
@@ -10,11 +11,11 @@ describe("AutofillInlineMenuButton", () => {
let autofillInlineMenuButton: AutofillInlineMenuButton;
const portKey: string = "inlineMenuButtonPortKey";
const expectedOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1) || "chrome-extension://id";
beforeEach(() => {
document.body.innerHTML = `<autofill-inline-menu-button></autofill-inline-menu-button>`;
autofillInlineMenuButton = document.querySelector("autofill-inline-menu-button");
autofillInlineMenuButton["messageOrigin"] = "https://localhost/";
jest.spyOn(globalThis.document, "createElement");
jest.spyOn(globalThis.parent, "postMessage");
});
@@ -56,8 +57,8 @@ describe("AutofillInlineMenuButton", () => {
autofillInlineMenuButton["buttonElement"].click();
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "autofillInlineMenuButtonClicked", portKey },
"*",
{ command: "autofillInlineMenuButtonClicked", portKey, token: "test-token" },
expectedOrigin,
);
});
});
@@ -70,7 +71,7 @@ describe("AutofillInlineMenuButton", () => {
it("does not post a message to close the autofill inline menu if the element is focused during the focus check", async () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" });
await flushPromises();
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
@@ -84,7 +85,7 @@ describe("AutofillInlineMenuButton", () => {
.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector")
.mockReturnValue(autofillInlineMenuButton["buttonElement"]);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" });
await flushPromises();
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
@@ -98,7 +99,7 @@ describe("AutofillInlineMenuButton", () => {
jest
.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector")
.mockReturnValue(autofillInlineMenuButton["buttonElement"]);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" });
await flushPromises();
globalThis.document.dispatchEvent(new MouseEvent("mouseout"));
@@ -113,12 +114,12 @@ describe("AutofillInlineMenuButton", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
jest.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector").mockReturnValue(null);
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" });
await flushPromises();
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "triggerDelayedAutofillInlineMenuClosure", portKey },
"*",
{ command: "triggerDelayedAutofillInlineMenuClosure", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -128,6 +129,7 @@ describe("AutofillInlineMenuButton", () => {
postWindowMessage({
command: "updateAutofillInlineMenuButtonAuthStatus",
authStatus: AuthenticationStatus.Unlocked,
token: "test-token",
});
await flushPromises();
@@ -143,6 +145,7 @@ describe("AutofillInlineMenuButton", () => {
postWindowMessage({
command: "updateAutofillInlineMenuColorScheme",
colorScheme: "dark",
token: "test-token",
});
await flushPromises();

View File

@@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background";
import {
createAutofillOverlayCipherDataMock,
@@ -23,6 +24,7 @@ describe("AutofillInlineMenuList", () => {
let autofillInlineMenuList: AutofillInlineMenuList | null;
const portKey: string = "inlineMenuListPortKey";
const expectedOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1) || "chrome-extension://id";
const events: { eventName: any; callback: any }[] = [];
beforeEach(() => {
@@ -67,8 +69,8 @@ describe("AutofillInlineMenuList", () => {
unlockButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "unlockVault", portKey },
"*",
{ command: "unlockVault", portKey, token: "test-token" },
expectedOrigin,
);
});
});
@@ -134,8 +136,13 @@ describe("AutofillInlineMenuList", () => {
addVaultItemButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem", portKey, addNewCipherType: CipherType.Login },
"*",
{
command: "addNewVaultItem",
portKey,
addNewCipherType: CipherType.Login,
token: "test-token",
},
expectedOrigin,
);
});
});
@@ -324,8 +331,9 @@ describe("AutofillInlineMenuList", () => {
inlineMenuCipherId: "1",
usePasskey: false,
portKey,
token: "test-token",
},
"*",
expectedOrigin,
);
});
@@ -492,8 +500,13 @@ describe("AutofillInlineMenuList", () => {
viewCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "viewSelectedCipher", inlineMenuCipherId: "1", portKey },
"*",
{
command: "viewSelectedCipher",
inlineMenuCipherId: "1",
portKey,
token: "test-token",
},
expectedOrigin,
);
});
@@ -581,8 +594,13 @@ describe("AutofillInlineMenuList", () => {
newVaultItemButtonSpy.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem", portKey, addNewCipherType: CipherType.Login },
"*",
{
command: "addNewVaultItem",
portKey,
addNewCipherType: CipherType.Login,
token: "test-token",
},
expectedOrigin,
);
});
@@ -826,8 +844,8 @@ describe("AutofillInlineMenuList", () => {
fillGeneratedPasswordButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
{ command: "fillGeneratedPassword", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -843,7 +861,7 @@ describe("AutofillInlineMenuList", () => {
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
expectedOrigin,
);
});
@@ -857,8 +875,8 @@ describe("AutofillInlineMenuList", () => {
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "fillGeneratedPassword", portKey },
"*",
{ command: "fillGeneratedPassword", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -896,8 +914,8 @@ describe("AutofillInlineMenuList", () => {
refreshGeneratedPasswordButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
{ command: "refreshGeneratedPassword", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -913,7 +931,7 @@ describe("AutofillInlineMenuList", () => {
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
expectedOrigin,
);
});
@@ -927,8 +945,8 @@ describe("AutofillInlineMenuList", () => {
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "refreshGeneratedPassword", portKey },
"*",
{ command: "refreshGeneratedPassword", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -972,7 +990,7 @@ describe("AutofillInlineMenuList", () => {
it("does not post a `checkAutofillInlineMenuButtonFocused` message to the parent if the inline menu is currently focused", () => {
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
@@ -983,7 +1001,7 @@ describe("AutofillInlineMenuList", () => {
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" });
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
});
@@ -994,7 +1012,7 @@ describe("AutofillInlineMenuList", () => {
jest
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" });
await flushPromises();
globalThis.document.dispatchEvent(new MouseEvent("mouseout"));
@@ -1010,11 +1028,11 @@ describe("AutofillInlineMenuList", () => {
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector")
.mockReturnValue(null);
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" });
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "checkAutofillInlineMenuButtonFocused", portKey },
"*",
{ command: "checkAutofillInlineMenuButtonFocused", portKey, token: "test-token" },
expectedOrigin,
);
});
@@ -1022,7 +1040,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
const updateCiphersSpy = jest.spyOn(autofillInlineMenuList as any, "updateListItems");
postWindowMessage({ command: "updateAutofillInlineMenuListCiphers" });
postWindowMessage({ command: "updateAutofillInlineMenuListCiphers", token: "test-token" });
expect(updateCiphersSpy).toHaveBeenCalled();
});
@@ -1062,7 +1080,10 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
await flushPromises();
postWindowMessage({ command: "updateAutofillInlineMenuGeneratedPassword" });
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
token: "test-token",
});
expect(buildColorizedPasswordElementSpy).not.toHaveBeenCalled();
});
@@ -1074,6 +1095,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
generatedPassword,
token: "test-token",
});
expect(buildPasswordGeneratorSpy).toHaveBeenCalled();
@@ -1090,6 +1112,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage({
command: "updateAutofillInlineMenuGeneratedPassword",
generatedPassword,
token: "test-token",
});
expect(buildPasswordGeneratorSpy).toHaveBeenCalledTimes(1);
@@ -1115,7 +1138,7 @@ describe("AutofillInlineMenuList", () => {
);
await flushPromises();
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
postWindowMessage({ command: "showSaveLoginInlineMenuList", token: "test-token" });
expect(buildSaveLoginInlineMenuSpy).not.toHaveBeenCalled();
});
@@ -1124,7 +1147,7 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
await flushPromises();
postWindowMessage({ command: "showSaveLoginInlineMenuList" });
postWindowMessage({ command: "showSaveLoginInlineMenuList", token: "test-token" });
expect(buildSaveLoginInlineMenuSpy).toHaveBeenCalled();
});
@@ -1143,7 +1166,7 @@ describe("AutofillInlineMenuList", () => {
"setAttribute",
);
postWindowMessage({ command: "focusAutofillInlineMenuList" });
postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" });
expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("role", "dialog");
expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("aria-modal", "true");
@@ -1161,7 +1184,7 @@ describe("AutofillInlineMenuList", () => {
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#unlock-button");
jest.spyOn(unlockButton as HTMLElement, "focus");
postWindowMessage({ command: "focusAutofillInlineMenuList" });
postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" });
expect((unlockButton as HTMLElement).focus).toBeCalled();
});
@@ -1173,7 +1196,7 @@ describe("AutofillInlineMenuList", () => {
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#new-item-button");
jest.spyOn(newItemButton as HTMLElement, "focus");
postWindowMessage({ command: "focusAutofillInlineMenuList" });
postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" });
expect((newItemButton as HTMLElement).focus).toBeCalled();
});
@@ -1184,7 +1207,7 @@ describe("AutofillInlineMenuList", () => {
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button");
jest.spyOn(firstCipherItem as HTMLElement, "focus");
postWindowMessage({ command: "focusAutofillInlineMenuList" });
postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" });
expect((firstCipherItem as HTMLElement).focus).toBeCalled();
});
@@ -1197,8 +1220,8 @@ describe("AutofillInlineMenuList", () => {
globalThis.dispatchEvent(new Event("blur"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "autofillInlineMenuBlurred", portKey },
"*",
{ command: "autofillInlineMenuBlurred", portKey, token: "test-token" },
expectedOrigin,
);
});
});
@@ -1220,8 +1243,13 @@ describe("AutofillInlineMenuList", () => {
);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectAutofillInlineMenuFocusOut", direction: "previous", portKey },
"*",
{
command: "redirectAutofillInlineMenuFocusOut",
direction: "previous",
portKey,
token: "test-token",
},
expectedOrigin,
);
});
@@ -1229,8 +1257,13 @@ describe("AutofillInlineMenuList", () => {
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectAutofillInlineMenuFocusOut", direction: "next", portKey },
"*",
{
command: "redirectAutofillInlineMenuFocusOut",
direction: "next",
portKey,
token: "test-token",
},
expectedOrigin,
);
});
@@ -1238,8 +1271,13 @@ describe("AutofillInlineMenuList", () => {
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "redirectAutofillInlineMenuFocusOut", direction: "current", portKey },
"*",
{
command: "redirectAutofillInlineMenuFocusOut",
direction: "current",
portKey,
token: "test-token",
},
expectedOrigin,
);
});
});
@@ -1274,8 +1312,13 @@ describe("AutofillInlineMenuList", () => {
autofillInlineMenuList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "updateAutofillInlineMenuListHeight", styles: { height: "300px" }, portKey },
"*",
{
command: "updateAutofillInlineMenuListHeight",
styles: { height: "300px" },
portKey,
token: "test-token",
},
expectedOrigin,
);
});
});

View File

@@ -1,5 +1,6 @@
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import { generateRandomChars, setElementStyles } from "../../../../utils";
import {
InitAutofillInlineMenuElementMessage,
@@ -73,7 +74,7 @@ export class AutofillInlineMenuContainer {
constructor() {
this.token = generateRandomChars(32);
this.extensionOrigin = chrome.runtime.getURL("").slice(0, -1);
this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1);
globalThis.addEventListener("message", this.handleWindowMessage);
}
@@ -203,6 +204,9 @@ export class AutofillInlineMenuContainer {
*/
private handleWindowMessage = (event: MessageEvent<AutofillInlineMenuContainerWindowMessage>) => {
const message = event.data;
if (!message?.command) {
return;
}
if (this.isForeignWindowMessage(event)) {
return;
}
@@ -287,7 +291,10 @@ export class AutofillInlineMenuContainer {
* every time the inline menu container is recreated.
*
*/
private isValidSessionToken(message: { token?: string }): boolean {
private isValidSessionToken(message: { token: string }): boolean {
if (!this.token || !message?.token || !message?.token.length) {
return false;
}
return message.token === this.token;
}

View File

@@ -38,12 +38,8 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
styleSheetUrl: string,
translations: Record<string, string>,
portKey: string,
token?: string,
): Promise<HTMLLinkElement> {
this.portKey = portKey;
if (token) {
this.token = token;
}
this.translations = translations;
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
@@ -63,11 +59,16 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
* @param message - The message to post
*/
protected postMessageToParent(message: AutofillInlineMenuPageElementWindowMessage) {
const messageWithAuth: Record<string, unknown> = { portKey: this.portKey, ...message };
if (this.token) {
messageWithAuth.token = this.token;
// never send messages containing authentication tokens without a valid token and an established messageOrigin
if (!this.token || !this.messageOrigin) {
return;
}
globalThis.parent.postMessage(messageWithAuth, "*");
const messageWithAuth: Record<string, unknown> = {
portKey: this.portKey,
...message,
token: this.token,
};
globalThis.parent.postMessage(messageWithAuth, this.messageOrigin);
}
/**
@@ -105,6 +106,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
return;
}
if (event.source !== globalThis.parent) {
return;
}
if (!this.messageOrigin) {
this.messageOrigin = event.origin;
}
@@ -115,12 +120,23 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
const message = event?.data;
if (
message?.token &&
(message?.command === "initAutofillInlineMenuButton" ||
message?.command === "initAutofillInlineMenuList")
) {
if (!message?.command) {
return;
}
const isInitCommand =
message.command === "initAutofillInlineMenuButton" ||
message.command === "initAutofillInlineMenuList";
if (isInitCommand) {
if (!message?.token) {
return;
}
this.token = message.token;
} else {
if (!this.token || !message?.token || message.token !== this.token) {
return;
}
}
const handler = this.windowMessageHandlers[message?.command];

View File

@@ -7,7 +7,7 @@ exports[`OverlayNotificationsContentService opening the notification bar creates
>
<iframe
id="bit-notification-bar-iframe"
src="chrome-extension://id/notification/bar.html"
src="chrome-extension://id/notification/bar.html?parentOrigin=http%3A%2F%2Flocalhost"
style="width: 100% !important; height: 100% !important; border: 0px !important; display: block !important; position: relative !important; transition: transform 0.15s ease-out, opacity 0.15s ease !important; border-radius: 4px !important; color-scheme: auto !important; transform: translateX(0) !important; opacity: 0;"
/>
</div>

View File

@@ -155,8 +155,9 @@ describe("OverlayNotificationsContentService", () => {
{
command: "initNotificationBar",
initData: expect.any(Object),
parentOrigin: expect.any(String),
},
"*",
overlayNotificationsContentService["extensionOrigin"],
);
});
});
@@ -257,7 +258,7 @@ describe("OverlayNotificationsContentService", () => {
expect(postMessageSpy).toHaveBeenCalledWith(
{ command: "saveCipherAttemptCompleted", error: undefined },
"*",
overlayNotificationsContentService["extensionOrigin"],
);
});
});

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import {
NotificationBarIframeInitData,
NotificationType,
@@ -22,6 +23,7 @@ export class OverlayNotificationsContentService
private notificationBarIframeElement: HTMLIFrameElement | null = null;
private notificationBarShadowRoot: ShadowRoot | null = null;
private currentNotificationBarType: NotificationType | null = null;
private readonly extensionOrigin: string;
private notificationBarContainerStyles: Partial<CSSStyleDeclaration> = {
height: "400px",
width: "430px",
@@ -61,6 +63,7 @@ export class OverlayNotificationsContentService
};
constructor() {
this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1);
void sendExtensionMessage("checkNotificationQueue");
}
@@ -181,7 +184,10 @@ export class OverlayNotificationsContentService
this.currentNotificationBarType = initData.type;
this.notificationBarIframeElement = globalThis.document.createElement("iframe");
this.notificationBarIframeElement.id = "bit-notification-bar-iframe";
this.notificationBarIframeElement.src = chrome.runtime.getURL("notification/bar.html");
const parentOrigin = globalThis.location.origin;
const iframeUrl = new URL(BrowserApi.getRuntimeURL("notification/bar.html"));
iframeUrl.searchParams.set("parentOrigin", parentOrigin);
this.notificationBarIframeElement.src = iframeUrl.toString();
setElementStyles(
this.notificationBarIframeElement,
{
@@ -254,7 +260,11 @@ export class OverlayNotificationsContentService
return;
}
this.sendMessageToNotificationBarIframe({ command: "initNotificationBar", initData });
this.sendMessageToNotificationBarIframe({
command: "initNotificationBar",
initData,
parentOrigin: globalThis.location.origin,
});
globalThis.removeEventListener("message", handleInitNotificationBarMessage);
};
@@ -303,7 +313,7 @@ export class OverlayNotificationsContentService
*/
private sendMessageToNotificationBarIframe(message: Record<string, any>) {
if (this.notificationBarIframeElement) {
this.notificationBarIframeElement.contentWindow.postMessage(message, "*");
this.notificationBarIframeElement.contentWindow.postMessage(message, this.extensionOrigin);
}
}

View File

@@ -6,5 +6,5 @@ export interface DomQueryService {
mutationObserver?: MutationObserver,
forceDeepQueryAttempt?: boolean,
): T[];
checkPageContainsShadowDom(): boolean;
checkPageContainsShadowDom(): void;
}

View File

@@ -395,7 +395,7 @@ describe("CollectAutofillContentService", () => {
});
});
it("sets the noFieldsFound property to true if the page has no forms or fields", async function () {
it("sets the noFieldsFond property to true if the page has no forms or fields", async function () {
document.body.innerHTML = "";
collectAutofillContentService["noFieldsFound"] = false;
jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData");
@@ -2649,33 +2649,4 @@ describe("CollectAutofillContentService", () => {
);
});
});
describe("processMutations", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it("will require an update to page details if shadow DOM is present", () => {
jest
.spyOn(domQueryService as any, "checkPageContainsShadowDom")
.mockImplementationOnce(() => true);
collectAutofillContentService["requirePageDetailsUpdate"] = jest.fn();
collectAutofillContentService["mutationsQueue"] = [[], []];
collectAutofillContentService["processMutations"]();
jest.runOnlyPendingTimers();
expect(domQueryService.checkPageContainsShadowDom).toHaveBeenCalled();
expect(collectAutofillContentService["mutationsQueue"]).toHaveLength(0);
expect(collectAutofillContentService["requirePageDetailsUpdate"]).toHaveBeenCalled();
});
});
});

View File

@@ -997,13 +997,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
* within an idle callback to help with performance and prevent excessive updates.
*/
private processMutations = () => {
// If the page contains shadow DOM, we require a page details update from the autofill service.
// Will wait for an idle moment on main thread to execute, unless timeout has passed.
requestIdleCallbackPolyfill(
() => this.domQueryService.checkPageContainsShadowDom() && this.requirePageDetailsUpdate(),
{ timeout: 500 },
);
const queueLength = this.mutationsQueue.length;
for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) {
@@ -1026,13 +1019,13 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
* Triggers several flags that indicate that a collection of page details should
* occur again on a subsequent call after a mutation has been observed in the DOM.
*/
private requirePageDetailsUpdate = () => {
private flagPageDetailsUpdateIsRequired() {
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
}
this.noFieldsFound = false;
};
}
/**
* Processes all mutation records encountered by the mutation observer.
@@ -1060,7 +1053,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
this.isAutofillElementNodeMutated(mutation.addedNodes))
) {
this.requirePageDetailsUpdate();
this.flagPageDetailsUpdateIsRequired();
return;
}

View File

@@ -72,6 +72,7 @@ describe("DomQueryService", () => {
});
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
domQueryService["pageContainsShadowDom"] = true;
const root = document.createElement("div");
const shadowRoot1 = root.attachShadow({ mode: "open" });
const root2 = document.createElement("div");
@@ -94,6 +95,7 @@ describe("DomQueryService", () => {
});
it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => {
domQueryService["pageContainsShadowDom"] = true;
const root = document.createElement("div");
const shadowRoot1 = root.attachShadow({ mode: "open" });
const root2 = document.createElement("div");

View File

@@ -78,9 +78,8 @@ export class DomQueryService implements DomQueryServiceInterface {
/**
* Checks if the page contains any shadow DOM elements.
*/
checkPageContainsShadowDom = (): boolean => {
checkPageContainsShadowDom = (): void => {
this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0;
return this.pageContainsShadowDom;
};
/**
@@ -109,7 +108,7 @@ export class DomQueryService implements DomQueryServiceInterface {
): T[] {
let elements = this.queryElements<T>(root, queryString);
const shadowRoots = this.pageContainsShadowDom ? this.recursivelyQueryShadowRoots(root) : [];
const shadowRoots = this.recursivelyQueryShadowRoots(root);
for (let index = 0; index < shadowRoots.length; index++) {
const shadowRoot = shadowRoots[index];
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
@@ -152,6 +151,10 @@ export class DomQueryService implements DomQueryServiceInterface {
root: Document | ShadowRoot | Element,
depth: number = 0,
): ShadowRoot[] {
if (!this.pageContainsShadowDom) {
return [];
}
if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) {
throw new Error("Max recursion depth reached");
}

View File

@@ -175,6 +175,7 @@ export function createInitAutofillInlineMenuButtonMessageMock(
styleSheetUrl: "https://jest-testing-website.com",
authStatus: AuthenticationStatus.Unlocked,
portKey: "portKey",
token: "test-token",
...customFields,
};
}
@@ -212,6 +213,7 @@ export function createInitAutofillInlineMenuListMessageMock(
theme: ThemeTypes.Light,
authStatus: AuthenticationStatus.Unlocked,
portKey: "portKey",
token: "test-token",
inlineMenuFillType: CipherType.Login,
ciphers: [
createAutofillOverlayCipherDataMock(1, {

View File

@@ -1,5 +1,7 @@
import { mock } from "jest-mock-extended";
import { BrowserApi } from "../../platform/browser/browser-api";
export function triggerTestFailure() {
expect(true).toBe("Test has failed.");
}
@@ -11,7 +13,11 @@ export function flushPromises() {
});
}
export function postWindowMessage(data: any, origin = "https://localhost/", source = window) {
export function postWindowMessage(
data: any,
origin: string = BrowserApi.getRuntimeURL("")?.slice(0, -1),
source: Window | MessageEventSource | null = window,
) {
globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source }));
}

View File

@@ -728,7 +728,9 @@ export default class MainBackground {
this.appIdService = new AppIdService(this.storageService, this.logService);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(
this.singleUserStateProvider,
);
this.organizationService = new DefaultOrganizationService(this.stateProvider);
this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService);
@@ -859,8 +861,6 @@ export default class MainBackground {
this.stateProvider,
);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
this.devicesApiService = new DevicesApiServiceImplementation(this.apiService);
this.deviceTrustService = new DeviceTrustService(
this.keyGenerationService,
@@ -876,6 +876,7 @@ export default class MainBackground {
this.userDecryptionOptionsService,
this.logService,
this.configService,
this.accountService,
);
this.devicesService = new DevicesServiceImplementation(

View File

@@ -92,7 +92,7 @@ import "../platform/popup/locales";
TabsV2Component,
RemovePasswordComponent,
],
exports: [],
exports: [CalloutModule],
providers: [CurrencyPipe, DatePipe],
bootstrap: [AppComponent],
})

View File

@@ -36,6 +36,7 @@ import {
LoginEmailService,
SsoUrlService,
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -607,7 +608,12 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: Fido2UserVerificationService,
useClass: Fido2UserVerificationService,
deps: [PasswordRepromptService, UserVerificationService, DialogService],
deps: [
PasswordRepromptService,
UserDecryptionOptionsServiceAbstraction,
DialogService,
AccountServiceAbstraction,
],
}),
safeProvider({
provide: AnimationControlService,

View File

@@ -24,7 +24,7 @@
class="tw-text-sm tw-font-medium tw-cursor-pointer"
(click)="toggleSavedUrlExpandedState()"
>
{{ (savedUrlsExpanded() ? "viewLess" : "viewAll") | i18n }}
{{ (savedUrlsExpanded() ? "showLess" : "showAll") | i18n }}
</button>
</div>
<div class="tw-pt-2" [ngClass]="savedUrlsListClass()">

View File

@@ -91,6 +91,11 @@ describe("AutofillConfirmationDialogComponent", () => {
jest.resetAllMocks();
});
const findShowAll = (inFx?: ComponentFixture<AutofillConfirmationDialogComponent>) =>
(inFx || fixture).nativeElement.querySelector(
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
) as HTMLButtonElement | null;
it("normalizes currentUrl and savedUrls via Utils.getHostname", () => {
expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0));
expect(component.currentUrl()).toBe("example.com");
@@ -191,21 +196,47 @@ describe("AutofillConfirmationDialogComponent", () => {
expect(text).toContain("two.example.com");
});
it("shows the 'view all' button when savedUrls > 1 and toggles the button text when clicked", () => {
const findViewAll = () =>
fixture.nativeElement.querySelector(
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
) as HTMLButtonElement | null;
let btn = findViewAll();
it("shows the 'show all' button when savedUrls > 1", () => {
const btn = findShowAll();
expect(btn).toBeTruthy();
expect(btn!.textContent).toContain("showAll");
});
it('hides the "show all" button when savedUrls is empty', async () => {
const newParams: AutofillConfirmationDialogParams = {
currentUrl: "https://bitwarden.com/help",
savedUrls: [],
};
const { fixture: vf } = await createFreshFixture({ params: newParams });
vf.detectChanges();
const btn = findShowAll(vf);
expect(btn).toBeNull();
});
it("handles toggling of the 'show all' button correctly", async () => {
const { fixture: vf, component: vc } = await createFreshFixture();
let btn = findShowAll(vf);
expect(btn).toBeTruthy();
expect(vc.savedUrlsExpanded()).toBe(false);
expect(btn!.textContent).toContain("showAll");
// click to expand
btn!.click();
fixture.detectChanges();
vf.detectChanges();
btn = findViewAll();
expect(btn!.textContent).toContain("viewLess");
expect(component.savedUrlsExpanded()).toBe(true);
btn = findShowAll(vf);
expect(btn!.textContent).toContain("showLess");
expect(vc.savedUrlsExpanded()).toBe(true);
// click to collapse
btn!.click();
vf.detectChanges();
btn = findShowAll(vf);
expect(btn!.textContent).toContain("showAll");
expect(vc.savedUrlsExpanded()).toBe(false);
});
it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => {

View File

@@ -10,7 +10,7 @@ import {
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
import { CopyableCipherFields } from "@bitwarden/sdk-internal";
import { CopyAction, CopyCipherFieldDirective } from "@bitwarden/vault";
import { CopyFieldAction, CopyCipherFieldDirective } from "@bitwarden/vault";
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
@@ -18,7 +18,7 @@ type CipherItem = {
/** Translation key for the respective value */
key: string;
/** Property key on `CipherView` to retrieve the copy value */
field: CopyAction;
field: CopyFieldAction;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -48,7 +48,7 @@ export class ItemCopyActionsComponent {
* singleCopyableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP
* code to be copied correctly. See #14167
*/
get singleCopyableLogin() {
get singleCopyableLogin(): CipherItem | null {
const loginItems: CipherItem[] = [
{ key: "copyUsername", field: "username" },
{ key: "copyPassword", field: "password" },
@@ -62,7 +62,7 @@ export class ItemCopyActionsComponent {
) {
return {
key: this.i18nService.t("copyUsername"),
field: "username",
field: "username" as const,
};
}
return this.findSingleCopyableItem(loginItems);

View File

@@ -51,10 +51,26 @@
{{ "assignToCollections" | i18n }}
</a>
</ng-container>
@if (canArchive$ | async) {
<button type="button" bitMenuItem (click)="archive()">
{{ "archiveVerb" | i18n }}
</button>
@if (showArchive$ | async) {
@if (canArchive$ | async) {
<button type="button" bitMenuItem (click)="archive()">
{{ "archiveVerb" | i18n }}
</button>
} @else {
<button
type="button"
bitMenuItem
(click)="badge.promptForPremium($event)"
[attr.aria-label]="'upgradeToUseArchive' | i18n"
>
<div class="tw-flex tw-flex-nowrap tw-items-center tw-gap-2">
{{ "archiveVerb" | i18n }}
<div aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</div>
</button>
}
}
@if (canDelete$ | async) {
<button type="button" bitMenuItem (click)="delete()">

View File

@@ -106,7 +106,10 @@ describe("ItemMoreOptionsComponent", () => {
},
{ provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } },
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
{
provide: CipherArchiveService,
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
},
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
{ provide: PasswordRepromptService, useValue: passwordRepromptService },

View File

@@ -1,10 +1,11 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -17,6 +18,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -33,6 +35,7 @@ import {
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
@@ -46,7 +49,18 @@ import {
@Component({
selector: "app-item-more-options",
templateUrl: "./item-more-options.component.html",
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
imports: [
ItemModule,
IconButtonModule,
MenuModule,
CommonModule,
JslibModule,
RouterModule,
PremiumBadgeComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
})
export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherViewLike>({} as CipherViewLike);
@@ -127,18 +141,11 @@ export class ItemMoreOptionsComponent {
}),
);
/** Observable Boolean checking if item can show Archive menu option */
protected canArchive$ = combineLatest([
this._cipher$,
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
),
]).pipe(
filter(([cipher, userId]) => cipher != null && userId != null),
map(([cipher, canArchive]) => {
return canArchive && !CipherViewLikeUtils.isArchived(cipher) && cipher.organizationId == null;
}),
protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
);
protected canDelete$ = this._cipher$.pipe(
@@ -377,6 +384,11 @@ export class ItemMoreOptionsComponent {
}
async archive() {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveItem" },
content: { key: "archiveItemConfirmDesc" },

View File

@@ -27,10 +27,10 @@
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
@if (cipher.hasAttachments) {
@if (CipherViewLikeUtils.hasAttachments(cipher)) {
<i class="bwi bwi-paperclip bwi-sm" [appA11yTitle]="'attachments' | i18n"></i>
}
<span slot="secondary">{{ cipher.subTitle }}</span>
<span slot="secondary">{{ CipherViewLikeUtils.subtitle(cipher) }}</span>
</button>
<bit-item-action slot="end">
<button

View File

@@ -11,7 +11,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
DialogService,
IconButtonModule,
@@ -71,12 +74,14 @@ export class ArchiveComponent {
switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)),
);
protected CipherViewLikeUtils = CipherViewLikeUtils;
protected loading$ = this.archivedCiphers$.pipe(
map(() => false),
startWith(true),
);
async view(cipher: CipherView) {
async view(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;
}
@@ -86,7 +91,7 @@ export class ArchiveComponent {
});
}
async edit(cipher: CipherView) {
async edit(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;
}
@@ -96,7 +101,7 @@ export class ArchiveComponent {
});
}
async delete(cipher: CipherView) {
async delete(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;
}
@@ -113,7 +118,7 @@ export class ArchiveComponent {
const activeUserId = await firstValueFrom(this.userId$);
try {
await this.cipherService.softDeleteWithServer(cipher.id, activeUserId);
await this.cipherService.softDeleteWithServer(cipher.id as string, activeUserId);
} catch (e) {
this.logService.error(e);
return;
@@ -125,13 +130,16 @@ export class ArchiveComponent {
});
}
async unarchive(cipher: CipherView) {
async unarchive(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;
}
const activeUserId = await firstValueFrom(this.userId$);
await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, activeUserId);
await this.cipherArchiveService.unarchiveWithServer(
cipher.id as unknown as CipherId,
activeUserId,
);
this.toastService.showToast({
variant: "success",
@@ -139,12 +147,12 @@ export class ArchiveComponent {
});
}
async clone(cipher: CipherView) {
async clone(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;
}
if (cipher.login?.hasFido2Credentials) {
if (CipherViewLikeUtils.hasFido2Credentials(cipher)) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
@@ -171,8 +179,8 @@ export class ArchiveComponent {
* @param cipher
* @private
*/
private canInteract(cipher: CipherView) {
if (cipher.decryptionFailure) {
private canInteract(cipher: CipherViewLike) {
if (CipherViewLikeUtils.decryptionFailure(cipher)) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});

View File

@@ -25,11 +25,11 @@
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="cipher.hasAttachments"
*ngIf="hasAttachments(cipher)"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
<span slot="secondary">{{ getSubtitle(cipher) }}</span>
</button>
<ng-container slot="end" *ngIf="cipher.permissions.restore">
<bit-item-action>
@@ -45,7 +45,7 @@
type="button"
bitMenuItem
(click)="restore(cipher)"
*ngIf="!cipher.decryptionFailure"
*ngIf="!hasDecryptionFailure(cipher)"
>
{{ "restore" | i18n }}
</button>

View File

@@ -12,7 +12,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
IconButtonModule,
@@ -85,10 +84,40 @@ export class TrashListItemsContainerComponent {
return collections[0]?.name;
}
async restore(cipher: CipherView) {
/**
* Check if a cipher has attachments. CipherView has a hasAttachments getter,
* while CipherListView has an attachments count property.
*/
hasAttachments(cipher: PopupCipherViewLike): boolean {
if ("hasAttachments" in cipher) {
return cipher.hasAttachments;
}
return cipher.attachments > 0;
}
/**
* Get the subtitle for a cipher. CipherView has a subTitle getter,
* while CipherListView has a subtitle property.
*/
getSubtitle(cipher: PopupCipherViewLike): string | undefined {
if ("subTitle" in cipher) {
return cipher.subTitle;
}
return cipher.subtitle;
}
/**
* Check if a cipher has a decryption failure. CipherView has this property,
* while CipherListView does not.
*/
hasDecryptionFailure(cipher: PopupCipherViewLike): boolean {
return "decryptionFailure" in cipher && cipher.decryptionFailure;
}
async restore(cipher: PopupCipherViewLike) {
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.restoreWithServer(cipher.id, activeUserId);
await this.cipherService.restoreWithServer(cipher.id as string, activeUserId);
await this.router.navigate(["/trash"]);
this.toastService.showToast({
@@ -101,7 +130,7 @@ export class TrashListItemsContainerComponent {
}
}
async delete(cipher: CipherView) {
async delete(cipher: PopupCipherViewLike) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
@@ -120,7 +149,7 @@ export class TrashListItemsContainerComponent {
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.deleteWithServer(cipher.id, activeUserId);
await this.cipherService.deleteWithServer(cipher.id as string, activeUserId);
await this.router.navigate(["/trash"]);
this.toastService.showToast({
@@ -133,8 +162,9 @@ export class TrashListItemsContainerComponent {
}
}
async onViewCipher(cipher: CipherView) {
if (cipher.decryptionFailure) {
async onViewCipher(cipher: PopupCipherViewLike) {
// CipherListView doesn't have decryptionFailure, so we use optional chaining
if ("decryptionFailure" in cipher && cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
@@ -147,7 +177,7 @@ export class TrashListItemsContainerComponent {
}
await this.router.navigate(["/view-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
queryParams: { cipherId: cipher.id as string, type: cipher.type },
});
}
}

View File

@@ -34,13 +34,27 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@if (userCanArchive() || showArchiveFilter()) {
<bit-item>
<a bit-item-content routerLink="/archive">
{{ "archiveNoun" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
@if (showArchiveItem()) {
@if (userCanArchive()) {
<bit-item>
<a bit-item-content routerLink="/archive">
{{ "archiveNoun" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
} @else {
<bit-item>
<a bit-item-content [routerLink]="userHasArchivedItems() ? '/archive' : '/premium'">
<span class="tw-flex tw-items-center tw-gap-2">
{{ "archiveNoun" | i18n }}
@if (!userHasArchivedItems()) {
<app-premium-badge></app-premium-badge>
}
</span>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
}
}
<bit-item>
<a bit-item-content routerLink="/trash">

View File

@@ -2,14 +2,16 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs";
import { firstValueFrom, map, switchMap } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
@@ -18,6 +20,7 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { BrowserPremiumUpgradePromptService } from "../services/browser-premium-upgrade-prompt.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -32,20 +35,28 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent,
ItemModule,
BadgeComponent,
PremiumBadgeComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
})
export class VaultSettingsV2Component implements OnInit, OnDestroy {
lastSync = "--";
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
// Check if user is premium user, they will be able to archive items
protected readonly userCanArchive = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
// Check if user has archived items (does not check if user is premium)
protected readonly showArchiveFilter = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))),
protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
protected readonly userHasArchivedItems = toSignal(
this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((c) => c.length > 0)),
),
),
);
protected emptyVaultImportBadge$ = this.accountService.activeAccount$.pipe(

View File

@@ -1,11 +1,15 @@
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { PasswordRepromptService } from "@bitwarden/vault";
// FIXME (PM-22628): Popup imports are forbidden in background
@@ -31,21 +35,24 @@ describe("Fido2UserVerificationService", () => {
let fido2UserVerificationService: Fido2UserVerificationService;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let userVerificationService: MockProxy<UserVerificationService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let dialogService: MockProxy<DialogService>;
let accountService: FakeAccountService;
let cipher: CipherView;
beforeEach(() => {
passwordRepromptService = mock<PasswordRepromptService>();
userVerificationService = mock<UserVerificationService>();
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
dialogService = mock<DialogService>();
accountService = mockAccountServiceWith(newGuid() as UserId);
cipher = createCipherView();
fido2UserVerificationService = new Fido2UserVerificationService(
passwordRepromptService,
userVerificationService,
userDecryptionOptionsService,
dialogService,
accountService,
);
(UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({
@@ -67,7 +74,7 @@ describe("Fido2UserVerificationService", () => {
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
@@ -82,7 +89,7 @@ describe("Fido2UserVerificationService", () => {
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await fido2UserVerificationService.handleUserVerification(
true,
@@ -98,7 +105,7 @@ describe("Fido2UserVerificationService", () => {
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await fido2UserVerificationService.handleUserVerification(
true,
@@ -114,7 +121,7 @@ describe("Fido2UserVerificationService", () => {
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
@@ -176,7 +183,7 @@ describe("Fido2UserVerificationService", () => {
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
@@ -191,7 +198,7 @@ describe("Fido2UserVerificationService", () => {
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await fido2UserVerificationService.handleUserVerification(
false,
@@ -207,7 +214,7 @@ describe("Fido2UserVerificationService", () => {
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await fido2UserVerificationService.handleUserVerification(
false,
@@ -223,7 +230,7 @@ describe("Fido2UserVerificationService", () => {
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(

View File

@@ -3,7 +3,8 @@
import { firstValueFrom } from "rxjs";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -15,8 +16,9 @@ import { SetPinComponent } from "../../auth/popup/components/set-pin.component";
export class Fido2UserVerificationService {
constructor(
private passwordRepromptService: PasswordRepromptService,
private userVerificationService: UserVerificationService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private dialogService: DialogService,
private accountService: AccountService,
) {}
/**
@@ -78,7 +80,15 @@ export class Fido2UserVerificationService {
}
private async handleMasterPasswordReprompt(): Promise<boolean> {
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount?.id) {
return false;
}
const hasMasterPassword = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(activeAccount.id),
);
// TDE users have no master password, so we need to use the UserVerification prompt
return hasMasterPassword

View File

@@ -1,5 +1,8 @@
{
"extends": "../../tsconfig.base",
"angularCompilerOptions": {
"strictTemplates": true
},
"include": [
"src",
"../../libs/common/src/autofill/constants",

View File

@@ -88,7 +88,7 @@
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"semver": "7.7.3",
"tldts": "7.0.1",
"tldts": "7.0.18",
"zxcvbn": "4.4.2"
}
}

View File

@@ -512,7 +512,9 @@ export class ServiceContainer {
")";
this.biometricStateService = new DefaultBiometricStateService(this.stateProvider);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(
this.singleUserStateProvider,
);
this.ssoUrlService = new SsoUrlService();
this.organizationService = new DefaultOrganizationService(this.stateProvider);
@@ -702,6 +704,7 @@ export class ServiceContainer {
this.userDecryptionOptionsService,
this.logService,
this.configService,
this.accountService,
);
this.loginStrategyService = new LoginStrategyService(

View File

@@ -119,7 +119,9 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,

View File

@@ -123,7 +123,9 @@ describe("WebSetInitialPasswordService", () => {
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,

View File

@@ -1,11 +1,10 @@
import { Component, OnInit, OnDestroy } from "@angular/core";
import { firstValueFrom, from, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { firstValueFrom, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { HeaderModule } from "../../../layouts/header/header.module";
@@ -42,8 +41,7 @@ export class AccountComponent implements OnInit, OnDestroy {
constructor(
private accountService: AccountService,
private dialogService: DialogService,
private userVerificationService: UserVerificationService,
private configService: ConfigService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private organizationService: OrganizationService,
) {}
@@ -56,7 +54,7 @@ export class AccountComponent implements OnInit, OnDestroy {
map((organizations) => organizations.some((o) => o.userIsManagedByOrganization === true)),
);
const hasMasterPassword$ = from(this.userVerificationService.hasMasterPassword());
const hasMasterPassword$ = this.userDecryptionOptionsService.hasMasterPasswordById$(userId);
this.showChangeEmail$ = hasMasterPassword$;

View File

@@ -5,6 +5,8 @@ import { firstValueFrom } from "rxjs";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { InputPasswordFlow } from "@bitwarden/auth/angular";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CalloutModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -24,12 +26,15 @@ export class PasswordSettingsComponent implements OnInit {
constructor(
private router: Router,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
) {}
async ngOnInit() {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const userHasMasterPassword = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPassword$,
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
);
if (!userHasMasterPassword) {
await this.router.navigate(["/settings/security/two-factor"]);
return;

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DialogService } from "@bitwarden/components";
import { ChangeKdfModule } from "../../../key-management/change-kdf/change-kdf.module";
@@ -23,20 +22,28 @@ export class SecurityKeysComponent implements OnInit {
showChangeKdf = true;
constructor(
private userVerificationService: UserVerificationService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
) {}
async ngOnInit() {
this.showChangeKdf = await this.userVerificationService.hasMasterPassword();
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showChangeKdf = await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(userId),
);
}
async viewUserApiKey() {
const entityId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!entityId) {
throw new Error("Active account not found");
}
await ApiKeyComponent.open(this.dialogService, {
data: {
keyType: "user",
@@ -55,6 +62,11 @@ export class SecurityKeysComponent implements OnInit {
const entityId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (!entityId) {
throw new Error("Active account not found");
}
await ApiKeyComponent.open(this.dialogService, {
data: {
keyType: "user",

View File

@@ -1,7 +1,9 @@
import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -20,7 +22,8 @@ export class SecurityComponent implements OnInit {
consolidatedSessionTimeoutComponent$: Observable<boolean>;
constructor(
private userVerificationService: UserVerificationService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private accountService: AccountService,
private configService: ConfigService,
) {
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
@@ -29,6 +32,9 @@ export class SecurityComponent implements OnInit {
}
async ngOnInit() {
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showChangePassword = userId
? await firstValueFrom(this.userDecryptionOptionsService.hasMasterPasswordById$(userId))
: false;
}
}

View File

@@ -95,7 +95,10 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
combineLatest([
this.organization$,
resetPasswordPolicies$,
this.userDecryptionOptionsService.userDecryptionOptions$,
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)),
),
managingOrg$,
])
.pipe(takeUntil(this.destroy$))