mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
Merge branch 'main' into autofill/pm-8027-inline-menu-appears-within-input-fields-that-do-not-relate-to-user-login
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2024.5.0",
|
||||
"version": "2024.5.1",
|
||||
"scripts": {
|
||||
"build": "cross-env MANIFEST_VERSION=3 webpack",
|
||||
"build:mv2": "webpack",
|
||||
|
||||
@@ -42,6 +42,7 @@ type OverlayPortMessage = {
|
||||
type FocusedFieldData = {
|
||||
focusedFieldStyles: Partial<CSSStyleDeclaration>;
|
||||
focusedFieldRects: Partial<DOMRect>;
|
||||
tabId?: number;
|
||||
};
|
||||
|
||||
type OverlayCipherData = {
|
||||
@@ -66,14 +67,14 @@ type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSende
|
||||
type OverlayBackgroundExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
openAutofillOverlay: () => void;
|
||||
autofillOverlayElementClosed: ({ message }: BackgroundMessageParam) => void;
|
||||
autofillOverlayElementClosed: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
getAutofillOverlayVisibility: () => void;
|
||||
checkAutofillOverlayFocused: () => void;
|
||||
focusAutofillOverlayList: () => void;
|
||||
updateAutofillOverlayPosition: ({ message }: BackgroundMessageParam) => void;
|
||||
updateAutofillOverlayPosition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void;
|
||||
updateFocusedFieldData: ({ message }: BackgroundMessageParam) => void;
|
||||
updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
unlockCompleted: ({ message }: BackgroundMessageParam) => void;
|
||||
addEditCipherSubmitted: () => void;
|
||||
|
||||
@@ -517,7 +517,7 @@ describe("OverlayBackground", () => {
|
||||
|
||||
expect(returnValue).toBe(undefined);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message);
|
||||
expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message, sender);
|
||||
});
|
||||
|
||||
it("will return a response if the message handler returns a response", async () => {
|
||||
@@ -570,6 +570,26 @@ describe("OverlayBackground", () => {
|
||||
await initOverlayElementPorts();
|
||||
});
|
||||
|
||||
it("disconnects any expired ports if the sender is not from the same page as the most recently focused field", () => {
|
||||
const port1 = mock<chrome.runtime.Port>();
|
||||
const port2 = mock<chrome.runtime.Port>();
|
||||
overlayBackground["expiredPorts"] = [port1, port2];
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
|
||||
const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 });
|
||||
sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData });
|
||||
|
||||
sendExtensionRuntimeMessage(
|
||||
{
|
||||
command: "autofillOverlayElementClosed",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
},
|
||||
sender,
|
||||
);
|
||||
|
||||
expect(port1.disconnect).toHaveBeenCalled();
|
||||
expect(port2.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects the button element port", () => {
|
||||
sendExtensionRuntimeMessage({
|
||||
command: "autofillOverlayElementClosed",
|
||||
@@ -729,6 +749,23 @@ describe("OverlayBackground", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips updating the position if the most recently focused field is different than the message sender", () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
|
||||
const focusedFieldData = createFocusedFieldDataMock({ tabId: 2 });
|
||||
sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData });
|
||||
|
||||
sendExtensionRuntimeMessage({ command: "updateAutofillOverlayPosition" }, sender);
|
||||
|
||||
expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({
|
||||
command: "updateIframePosition",
|
||||
styles: expect.anything(),
|
||||
});
|
||||
expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({
|
||||
command: "updateIframePosition",
|
||||
styles: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the overlay button's position", () => {
|
||||
const focusedFieldData = createFocusedFieldDataMock();
|
||||
sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData });
|
||||
@@ -796,12 +833,14 @@ describe("OverlayBackground", () => {
|
||||
});
|
||||
|
||||
it("will post a message to the overlay list facilitating an update of the list's position", () => {
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
|
||||
const focusedFieldData = createFocusedFieldDataMock();
|
||||
sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData });
|
||||
|
||||
overlayBackground["updateOverlayPosition"]({
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
overlayBackground["updateOverlayPosition"](
|
||||
{ overlayElement: AutofillOverlayElement.List },
|
||||
sender,
|
||||
);
|
||||
sendExtensionRuntimeMessage({
|
||||
command: "updateAutofillOverlayPosition",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
@@ -1017,9 +1056,10 @@ describe("OverlayBackground", () => {
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css");
|
||||
expect(overlayBackground["getTranslations"]).toHaveBeenCalled();
|
||||
expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled();
|
||||
expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith({
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith(
|
||||
{ overlayElement: AutofillOverlayElement.List },
|
||||
listPortSpy.sender,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets up the overlay button port if the port connection is for the overlay button", async () => {
|
||||
@@ -1032,9 +1072,19 @@ describe("OverlayBackground", () => {
|
||||
expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled();
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css");
|
||||
expect(overlayBackground["getTranslations"]).toHaveBeenCalled();
|
||||
expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith({
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith(
|
||||
{ overlayElement: AutofillOverlayElement.Button },
|
||||
buttonPortSpy.sender,
|
||||
);
|
||||
});
|
||||
|
||||
it("stores an existing overlay port so that it can be disconnected at a later time", async () => {
|
||||
overlayBackground["overlayButtonPort"] = mock<chrome.runtime.Port>();
|
||||
|
||||
await initOverlayElementPorts({ initList: false, initButton: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(overlayBackground["expiredPorts"].length).toBe(1);
|
||||
});
|
||||
|
||||
it("gets the system theme", async () => {
|
||||
|
||||
@@ -54,19 +54,22 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
|
||||
private overlayButtonPort: chrome.runtime.Port;
|
||||
private overlayListPort: chrome.runtime.Port;
|
||||
private expiredPorts: chrome.runtime.Port[] = [];
|
||||
private focusedFieldData: FocusedFieldData;
|
||||
private overlayPageTranslations: Record<string, string>;
|
||||
private iconsServerUrl: string;
|
||||
private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = {
|
||||
openAutofillOverlay: () => this.openOverlay(false),
|
||||
autofillOverlayElementClosed: ({ message }) => this.overlayElementClosed(message),
|
||||
autofillOverlayElementClosed: ({ message, sender }) =>
|
||||
this.overlayElementClosed(message, sender),
|
||||
autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender),
|
||||
getAutofillOverlayVisibility: () => this.getOverlayVisibility(),
|
||||
checkAutofillOverlayFocused: () => this.checkOverlayFocused(),
|
||||
focusAutofillOverlayList: () => this.focusOverlayList(),
|
||||
updateAutofillOverlayPosition: ({ message }) => this.updateOverlayPosition(message),
|
||||
updateAutofillOverlayPosition: ({ message, sender }) =>
|
||||
this.updateOverlayPosition(message, sender),
|
||||
updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message),
|
||||
updateFocusedFieldData: ({ message }) => this.setFocusedFieldData(message),
|
||||
updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender),
|
||||
collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender),
|
||||
unlockCompleted: ({ message }) => this.unlockCompleted(message),
|
||||
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
|
||||
@@ -302,8 +305,18 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
* the list and button ports and sets them to null.
|
||||
*
|
||||
* @param overlayElement - The overlay element that was closed, either the list or button
|
||||
* @param sender - The sender of the port message
|
||||
*/
|
||||
private overlayElementClosed({ overlayElement }: OverlayBackgroundExtensionMessage) {
|
||||
private overlayElementClosed(
|
||||
{ overlayElement }: OverlayBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
if (sender.tab.id !== this.focusedFieldData?.tabId) {
|
||||
this.expiredPorts.forEach((port) => port.disconnect());
|
||||
this.expiredPorts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlayElement === AutofillOverlayElement.Button) {
|
||||
this.overlayButtonPort?.disconnect();
|
||||
this.overlayButtonPort = null;
|
||||
@@ -320,9 +333,13 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
* is based on the focused field's position and dimensions.
|
||||
*
|
||||
* @param overlayElement - The overlay element to update, either the list or button
|
||||
* @param sender - The sender of the port message
|
||||
*/
|
||||
private updateOverlayPosition({ overlayElement }: { overlayElement?: string }) {
|
||||
if (!overlayElement) {
|
||||
private updateOverlayPosition(
|
||||
{ overlayElement }: { overlayElement?: string },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
if (!overlayElement || sender.tab.id !== this.focusedFieldData?.tabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,9 +413,13 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
* Sets the focused field data to the data passed in the extension message.
|
||||
*
|
||||
* @param focusedFieldData - Contains the rects and styles of the focused field.
|
||||
* @param sender - The sender of the extension message
|
||||
*/
|
||||
private setFocusedFieldData({ focusedFieldData }: OverlayBackgroundExtensionMessage) {
|
||||
this.focusedFieldData = focusedFieldData;
|
||||
private setFocusedFieldData(
|
||||
{ focusedFieldData }: OverlayBackgroundExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
) {
|
||||
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -690,17 +711,11 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
private handlePortOnConnect = async (port: chrome.runtime.Port) => {
|
||||
const isOverlayListPort = port.name === AutofillOverlayPort.List;
|
||||
const isOverlayButtonPort = port.name === AutofillOverlayPort.Button;
|
||||
|
||||
if (!isOverlayListPort && !isOverlayButtonPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOverlayListPort) {
|
||||
this.overlayListPort = port;
|
||||
} else {
|
||||
this.overlayButtonPort = port;
|
||||
}
|
||||
|
||||
this.storeOverlayPort(port);
|
||||
port.onMessage.addListener(this.handleOverlayElementPortMessage);
|
||||
port.postMessage({
|
||||
command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`,
|
||||
@@ -710,13 +725,47 @@ class OverlayBackground implements OverlayBackgroundInterface {
|
||||
translations: this.getTranslations(),
|
||||
ciphers: isOverlayListPort ? await this.getOverlayCipherData() : null,
|
||||
});
|
||||
this.updateOverlayPosition({
|
||||
overlayElement: isOverlayListPort
|
||||
? AutofillOverlayElement.List
|
||||
: AutofillOverlayElement.Button,
|
||||
});
|
||||
this.updateOverlayPosition(
|
||||
{
|
||||
overlayElement: isOverlayListPort
|
||||
? AutofillOverlayElement.List
|
||||
: AutofillOverlayElement.Button,
|
||||
},
|
||||
port.sender,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores the connected overlay port and sets up any existing ports to be disconnected.
|
||||
*
|
||||
* @param port - The port to store
|
||||
| */
|
||||
private storeOverlayPort(port: chrome.runtime.Port) {
|
||||
if (port.name === AutofillOverlayPort.List) {
|
||||
this.storeExpiredOverlayPort(this.overlayListPort);
|
||||
this.overlayListPort = port;
|
||||
return;
|
||||
}
|
||||
|
||||
if (port.name === AutofillOverlayPort.Button) {
|
||||
this.storeExpiredOverlayPort(this.overlayButtonPort);
|
||||
this.overlayButtonPort = port;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When registering a new connection, we want to ensure that the port is disconnected.
|
||||
* This method places an existing port in the expiredPorts array to be disconnected
|
||||
* at a later time.
|
||||
*
|
||||
* @param port - The port to store in the expiredPorts array
|
||||
*/
|
||||
private storeExpiredOverlayPort(port: chrome.runtime.Port | null) {
|
||||
if (port) {
|
||||
this.expiredPorts.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles messages sent to the overlay list or button ports.
|
||||
*
|
||||
|
||||
@@ -186,9 +186,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
||||
|
||||
this.overlayButtonElement.remove();
|
||||
this.isOverlayButtonVisible = false;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
void this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
this.removeOverlayRepositionEventListeners();
|
||||
@@ -204,9 +202,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
||||
|
||||
this.overlayListElement.remove();
|
||||
this.isOverlayListVisible = false;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
void this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ function createFocusedFieldDataMock(customFields = {}) {
|
||||
paddingRight: "6px",
|
||||
paddingLeft: "6px",
|
||||
},
|
||||
tabId: 1,
|
||||
...customFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export default class MainBackground {
|
||||
ssoLoginService: SsoLoginServiceAbstraction;
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService;
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- Needed to give access to services module
|
||||
intraprocessMessagingSubject: Subject<Message<object>>;
|
||||
intraprocessMessagingSubject: Subject<Message<Record<string, unknown>>>;
|
||||
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||
scriptInjectorService: BrowserScriptInjectorService;
|
||||
kdfConfigService: kdfConfigServiceAbstraction;
|
||||
@@ -384,7 +384,7 @@ export default class MainBackground {
|
||||
this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService);
|
||||
this.storageService = new BrowserLocalStorageService();
|
||||
|
||||
this.intraprocessMessagingSubject = new Subject<Message<object>>();
|
||||
this.intraprocessMessagingSubject = new Subject<Message<Record<string, unknown>>>();
|
||||
|
||||
this.messagingService = MessageSender.combine(
|
||||
new SubjectMessageSender(this.intraprocessMessagingSubject),
|
||||
@@ -840,7 +840,12 @@ export default class MainBackground {
|
||||
this.authService,
|
||||
);
|
||||
|
||||
this.syncServiceListener = new SyncServiceListener(this.syncService, messageListener);
|
||||
this.syncServiceListener = new SyncServiceListener(
|
||||
this.syncService,
|
||||
messageListener,
|
||||
this.messagingService,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
this.eventUploadService = new EventUploadService(
|
||||
this.apiService,
|
||||
@@ -1170,7 +1175,7 @@ export default class MainBackground {
|
||||
this.contextMenusBackground?.init();
|
||||
await this.idleBackground.init();
|
||||
this.webRequestBackground?.startListening();
|
||||
this.syncServiceListener?.startListening();
|
||||
this.syncServiceListener?.listener$().subscribe();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.5.0",
|
||||
"version": "2024.5.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.5.0",
|
||||
"version": "2024.5.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -15,9 +15,9 @@ const HANDLED_ERRORS: Record<string, ErrorHandler> = {
|
||||
export class ChromeMessageSender implements MessageSender {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
send<T extends object>(
|
||||
send<T extends Record<string, unknown>>(
|
||||
commandDefinition: string | CommandDefinition<T>,
|
||||
payload: object | T = {},
|
||||
payload: Record<string, unknown> | T = {},
|
||||
): void {
|
||||
const command = getCommand(commandDefinition);
|
||||
chrome.runtime.sendMessage(Object.assign(payload, { command: command }), () => {
|
||||
|
||||
130
apps/browser/src/platform/sync/foreground-sync.service.spec.ts
Normal file
130
apps/browser/src/platform/sync/foreground-sync.service.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
|
||||
import { DO_FULL_SYNC, ForegroundSyncService, FullSyncMessage } from "./foreground-sync.service";
|
||||
import { FullSyncFinishedMessage } from "./sync-service.listener";
|
||||
|
||||
describe("ForegroundSyncService", () => {
|
||||
const stateService = mock<StateService>();
|
||||
const folderService = mock<InternalFolderService>();
|
||||
const folderApiService = mock<FolderApiServiceAbstraction>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
const logService = mock<LogService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
const collectionService = mock<CollectionService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const accountService = mock<AccountService>();
|
||||
const authService = mock<AuthService>();
|
||||
const sendService = mock<InternalSendService>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const messageListener = mock<MessageListener>();
|
||||
|
||||
const sut = new ForegroundSyncService(
|
||||
stateService,
|
||||
folderService,
|
||||
folderApiService,
|
||||
messageSender,
|
||||
logService,
|
||||
cipherService,
|
||||
collectionService,
|
||||
apiService,
|
||||
accountService,
|
||||
authService,
|
||||
sendService,
|
||||
sendApiService,
|
||||
messageListener,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("fullSync", () => {
|
||||
const getAndAssertRequestId = (doFullSyncMessage: Omit<FullSyncMessage, "requestId">) => {
|
||||
expect(messageSender.send).toHaveBeenCalledWith(
|
||||
DO_FULL_SYNC,
|
||||
// We don't know the request id since that is created internally
|
||||
expect.objectContaining(doFullSyncMessage),
|
||||
);
|
||||
|
||||
const message = messageSender.send.mock.calls[0][1];
|
||||
|
||||
if (!("requestId" in message) || typeof message.requestId !== "string") {
|
||||
throw new Error("requestId property of type string was expected on the sent message.");
|
||||
}
|
||||
|
||||
return message.requestId;
|
||||
};
|
||||
|
||||
it("correctly relays a successful fullSync", async () => {
|
||||
const messages = new Subject<FullSyncFinishedMessage>();
|
||||
messageListener.messages$.mockReturnValue(messages);
|
||||
const fullSyncPromise = sut.fullSync(true, false);
|
||||
expect(sut.syncInProgress).toBe(true);
|
||||
|
||||
const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false });
|
||||
|
||||
// Pretend the sync has finished
|
||||
messages.next({ successfully: true, errorMessage: null, requestId: requestId });
|
||||
|
||||
const result = await fullSyncPromise;
|
||||
|
||||
expect(sut.syncInProgress).toBe(false);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly relays an unsuccessful fullSync but does not throw if allowThrowOnError = false", async () => {
|
||||
const messages = new Subject<FullSyncFinishedMessage>();
|
||||
messageListener.messages$.mockReturnValue(messages);
|
||||
const fullSyncPromise = sut.fullSync(false, false);
|
||||
expect(sut.syncInProgress).toBe(true);
|
||||
|
||||
const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false });
|
||||
|
||||
// Pretend the sync has finished
|
||||
messages.next({
|
||||
successfully: false,
|
||||
errorMessage: "Error while syncing",
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
const result = await fullSyncPromise;
|
||||
|
||||
expect(sut.syncInProgress).toBe(false);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly relays an unsuccessful fullSync but and will throw if allowThrowOnError = true", async () => {
|
||||
const messages = new Subject<FullSyncFinishedMessage>();
|
||||
messageListener.messages$.mockReturnValue(messages);
|
||||
const fullSyncPromise = sut.fullSync(true, true);
|
||||
expect(sut.syncInProgress).toBe(true);
|
||||
|
||||
const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true });
|
||||
|
||||
// Pretend the sync has finished
|
||||
messages.next({
|
||||
successfully: false,
|
||||
errorMessage: "Error while syncing",
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
await expect(fullSyncPromise).rejects.toThrow("Error while syncing");
|
||||
|
||||
expect(sut.syncInProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firstValueFrom, timeout } from "rxjs";
|
||||
import { filter, firstValueFrom, of, timeout } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MessageListener,
|
||||
MessageSender,
|
||||
} from "@bitwarden/common/platform/messaging";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CoreSyncService } from "@bitwarden/common/platform/sync/internal";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
@@ -18,11 +19,11 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
|
||||
const SYNC_COMPLETED = new CommandDefinition<{ successfully: boolean }>("syncCompleted");
|
||||
export const DO_FULL_SYNC = new CommandDefinition<{
|
||||
forceSync: boolean;
|
||||
allowThrowOnError: boolean;
|
||||
}>("doFullSync");
|
||||
import { FULL_SYNC_FINISHED } from "./sync-service.listener";
|
||||
|
||||
export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string };
|
||||
|
||||
export const DO_FULL_SYNC = new CommandDefinition<FullSyncMessage>("doFullSync");
|
||||
|
||||
export class ForegroundSyncService extends CoreSyncService {
|
||||
constructor(
|
||||
@@ -59,18 +60,29 @@ export class ForegroundSyncService extends CoreSyncService {
|
||||
async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise<boolean> {
|
||||
this.syncInProgress = true;
|
||||
try {
|
||||
const requestId = Utils.newGuid();
|
||||
const syncCompletedPromise = firstValueFrom(
|
||||
this.messageListener.messages$(SYNC_COMPLETED).pipe(
|
||||
this.messageListener.messages$(FULL_SYNC_FINISHED).pipe(
|
||||
filter((m) => m.requestId === requestId),
|
||||
timeout({
|
||||
first: 10_000,
|
||||
first: 30_000,
|
||||
// If we haven't heard back in 30 seconds, just pretend we heard back about an unsuccesful sync.
|
||||
with: () => {
|
||||
throw new Error("Timeout while doing a fullSync call.");
|
||||
this.logService.warning(
|
||||
"ForegroundSyncService did not receive a message back in a reasonable time.",
|
||||
);
|
||||
return of({ successfully: false, errorMessage: "Sync timed out." });
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError });
|
||||
this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId });
|
||||
const result = await syncCompletedPromise;
|
||||
|
||||
if (allowThrowOnError && result.errorMessage != null) {
|
||||
throw new Error(result.errorMessage);
|
||||
}
|
||||
|
||||
return result.successfully;
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
|
||||
60
apps/browser/src/platform/sync/sync-service.listener.spec.ts
Normal file
60
apps/browser/src/platform/sync/sync-service.listener.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { tagAsExternal } from "@bitwarden/common/platform/messaging/helpers";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
import { FullSyncMessage } from "./foreground-sync.service";
|
||||
import { FULL_SYNC_FINISHED, SyncServiceListener } from "./sync-service.listener";
|
||||
|
||||
describe("SyncServiceListener", () => {
|
||||
const syncService = mock<SyncService>();
|
||||
const messageListener = mock<MessageListener>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
const messages = new Subject<FullSyncMessage>();
|
||||
messageListener.messages$.mockReturnValue(messages.asObservable().pipe(tagAsExternal()));
|
||||
const sut = new SyncServiceListener(syncService, messageListener, messageSender, logService);
|
||||
|
||||
describe("listener$", () => {
|
||||
it.each([true, false])(
|
||||
"calls full sync and relays outcome when sync is [successfully = %s]",
|
||||
async (value) => {
|
||||
const listener = sut.listener$();
|
||||
const emissionPromise = firstValueFrom(listener);
|
||||
|
||||
syncService.fullSync.mockResolvedValueOnce(value);
|
||||
messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
|
||||
|
||||
await emissionPromise;
|
||||
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
|
||||
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
|
||||
successfully: value,
|
||||
errorMessage: null,
|
||||
requestId: "1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("calls full sync and relays error message through messaging", async () => {
|
||||
const listener = sut.listener$();
|
||||
const emissionPromise = firstValueFrom(listener);
|
||||
|
||||
syncService.fullSync.mockRejectedValueOnce(new Error("SyncError"));
|
||||
messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
|
||||
|
||||
await emissionPromise;
|
||||
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
|
||||
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
|
||||
successfully: false,
|
||||
errorMessage: "SyncError",
|
||||
requestId: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,58 @@
|
||||
import { Subscription, concatMap, filter } from "rxjs";
|
||||
import { Observable, concatMap, filter } from "rxjs";
|
||||
|
||||
import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
CommandDefinition,
|
||||
MessageListener,
|
||||
MessageSender,
|
||||
isExternalMessage,
|
||||
} from "@bitwarden/common/platform/messaging";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
import { DO_FULL_SYNC } from "./foreground-sync.service";
|
||||
|
||||
export type FullSyncFinishedMessage = {
|
||||
successfully: boolean;
|
||||
errorMessage: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export const FULL_SYNC_FINISHED = new CommandDefinition<FullSyncFinishedMessage>(
|
||||
"fullSyncFinished",
|
||||
);
|
||||
|
||||
export class SyncServiceListener {
|
||||
constructor(
|
||||
private readonly syncService: SyncService,
|
||||
private readonly messageListener: MessageListener,
|
||||
private readonly messageSender: MessageSender,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
return this.messageListener
|
||||
.messages$(DO_FULL_SYNC)
|
||||
.pipe(
|
||||
filter((message) => isExternalMessage(message)),
|
||||
concatMap(async ({ forceSync, allowThrowOnError }) => {
|
||||
await this.syncService.fullSync(forceSync, allowThrowOnError);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
listener$(): Observable<void> {
|
||||
return this.messageListener.messages$(DO_FULL_SYNC).pipe(
|
||||
filter((message) => isExternalMessage(message)),
|
||||
concatMap(async ({ forceSync, allowThrowOnError, requestId }) => {
|
||||
await this.doFullSync(forceSync, allowThrowOnError, requestId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) {
|
||||
try {
|
||||
const result = await this.syncService.fullSync(forceSync, allowThrowOnError);
|
||||
this.messageSender.send(FULL_SYNC_FINISHED, {
|
||||
successfully: result,
|
||||
errorMessage: null,
|
||||
requestId,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logService.warning("Error while doing full sync in SyncServiceListener", err);
|
||||
this.messageSender.send(FULL_SYNC_FINISHED, {
|
||||
successfully: false,
|
||||
errorMessage: err?.message ?? "Unknown Sync Error",
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { map, share } from "rxjs";
|
||||
|
||||
import { Message } from "@bitwarden/common/platform/messaging";
|
||||
import { tagAsExternal } from "@bitwarden/common/platform/messaging/internal";
|
||||
|
||||
import { fromChromeEvent } from "../browser/from-chrome-event";
|
||||
@@ -20,7 +21,7 @@ export const fromChromeRuntimeMessaging = () => {
|
||||
|
||||
return message;
|
||||
}),
|
||||
tagAsExternal,
|
||||
tagAsExternal<Message<Record<string, unknown>>>(),
|
||||
share(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -524,7 +524,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MessageListener,
|
||||
useFactory: (subject: Subject<Message<object>>, ngZone: NgZone) =>
|
||||
useFactory: (subject: Subject<Message<Record<string, unknown>>>, ngZone: NgZone) =>
|
||||
new MessageListener(
|
||||
merge(
|
||||
subject.asObservable(), // For messages in the same context
|
||||
@@ -535,7 +535,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MessageSender,
|
||||
useFactory: (subject: Subject<Message<object>>, logService: LogService) =>
|
||||
useFactory: (subject: Subject<Message<Record<string, unknown>>>, logService: LogService) =>
|
||||
MessageSender.combine(
|
||||
new SubjectMessageSender(subject), // For sending messages in the same context
|
||||
new ChromeMessageSender(logService), // For sending messages to different contexts
|
||||
@@ -550,14 +550,14 @@ const safeProviders: SafeProvider[] = [
|
||||
// we need the same instance that our in memory background is utilizing.
|
||||
return getBgService("intraprocessMessagingSubject")();
|
||||
} else {
|
||||
return new Subject<Message<object>>();
|
||||
return new Subject<Message<Record<string, unknown>>>();
|
||||
}
|
||||
},
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MessageSender,
|
||||
useFactory: (subject: Subject<Message<object>>, logService: LogService) =>
|
||||
useFactory: (subject: Subject<Message<Record<string, unknown>>>, logService: LogService) =>
|
||||
MessageSender.combine(
|
||||
new SubjectMessageSender(subject), // For sending messages in the same context
|
||||
new ChromeMessageSender(logService), // For sending messages to different contexts
|
||||
@@ -576,7 +576,7 @@ const safeProviders: SafeProvider[] = [
|
||||
// There isn't a locally created background so we will communicate with
|
||||
// the true background through chrome apis, in that case, we can just create
|
||||
// one for ourself.
|
||||
return new Subject<Message<object>>();
|
||||
return new Subject<Message<Record<string, unknown>>>();
|
||||
}
|
||||
},
|
||||
deps: [],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="tw-mb-2">
|
||||
<bit-search
|
||||
[placeholder]="'search' | i18n"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
>
|
||||
</bit-search>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Output, EventEmitter } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { Subject, debounceTime } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
const SearchTextDebounceInterval = 200;
|
||||
|
||||
@Component({
|
||||
imports: [CommonModule, SearchModule, JslibModule, FormsModule],
|
||||
standalone: true,
|
||||
selector: "app-vault-v2-search",
|
||||
templateUrl: "vault-v2-search.component.html",
|
||||
})
|
||||
export class VaultV2SearchComponent {
|
||||
searchText: string;
|
||||
@Output() searchTextChanged = new EventEmitter<string>();
|
||||
|
||||
private searchText$ = new Subject<string>();
|
||||
|
||||
constructor() {
|
||||
this.searchText$
|
||||
.pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed())
|
||||
.subscribe((data) => {
|
||||
this.searchTextChanged.emit(data);
|
||||
});
|
||||
}
|
||||
|
||||
onSearchTextChanged() {
|
||||
this.searchText$.next(this.searchText);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
<ng-container *ngIf="!(showEmptyState$ | async)">
|
||||
<!-- TODO: Filter/search Section in PM-6824 and PM-6826.-->
|
||||
|
||||
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
|
||||
</app-vault-v2-search>
|
||||
|
||||
<div
|
||||
*ngIf="showNoResultsState$ | async"
|
||||
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
|
||||
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault",
|
||||
@@ -28,6 +29,7 @@ import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } fro
|
||||
VaultListItemsContainerComponent,
|
||||
ButtonModule,
|
||||
RouterLink,
|
||||
VaultV2SearchComponent,
|
||||
],
|
||||
})
|
||||
export class VaultV2Component implements OnInit, OnDestroy {
|
||||
@@ -48,6 +50,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
handleSearchTextChange(searchText: string) {
|
||||
this.vaultPopupItemsService.applyFilter(searchText);
|
||||
}
|
||||
|
||||
addCipher() {
|
||||
// TODO: Add currently filtered organization to query params if available
|
||||
void this.router.navigate(["/add-cipher"], {});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
@@ -19,6 +20,7 @@ describe("VaultPopupItemsService", () => {
|
||||
|
||||
const cipherServiceMock = mock<CipherService>();
|
||||
const vaultSettingsServiceMock = mock<VaultSettingsService>();
|
||||
const searchService = mock<SearchService>();
|
||||
|
||||
beforeEach(() => {
|
||||
allCiphers = cipherFactory(10);
|
||||
@@ -34,6 +36,7 @@ describe("VaultPopupItemsService", () => {
|
||||
cipherList[3].favorite = true;
|
||||
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable();
|
||||
searchService.searchCiphers.mockImplementation(async () => cipherList);
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
|
||||
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
|
||||
@@ -41,11 +44,19 @@ describe("VaultPopupItemsService", () => {
|
||||
jest
|
||||
.spyOn(BrowserApi, "getTabFromCurrentWindow")
|
||||
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
searchService,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
searchService,
|
||||
);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -73,7 +84,11 @@ describe("VaultPopupItemsService", () => {
|
||||
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable();
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
|
||||
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
searchService,
|
||||
);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
|
||||
@@ -99,7 +114,11 @@ describe("VaultPopupItemsService", () => {
|
||||
Object.values(allCiphers),
|
||||
);
|
||||
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
searchService,
|
||||
);
|
||||
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers.length).toBe(10);
|
||||
@@ -114,6 +133,24 @@ describe("VaultPopupItemsService", () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter autoFillCiphers$ down to search term", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
const searchText = "Login";
|
||||
|
||||
searchService.searchCiphers.mockImplementation(async () => {
|
||||
return cipherList.filter((cipher) => {
|
||||
return cipher.name.includes(searchText);
|
||||
});
|
||||
});
|
||||
|
||||
// there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types
|
||||
service.autoFillCiphers$.subscribe((ciphers) => {
|
||||
expect(ciphers[0].name.includes(searchText)).toBe(true);
|
||||
expect(ciphers.length).toBe(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("favoriteCiphers$", () => {
|
||||
@@ -131,6 +168,24 @@ describe("VaultPopupItemsService", () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter favoriteCiphers$ down to search term", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
const searchText = "Card 2";
|
||||
|
||||
searchService.searchCiphers.mockImplementation(async () => {
|
||||
return cipherList.filter((cipher) => {
|
||||
return cipher.name === searchText;
|
||||
});
|
||||
});
|
||||
|
||||
service.favoriteCiphers$.subscribe((ciphers) => {
|
||||
// There are 2 favorite items but only one Card 2
|
||||
expect(ciphers[0].name).toBe(searchText);
|
||||
expect(ciphers.length).toBe(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("remainingCiphers$", () => {
|
||||
@@ -148,12 +203,33 @@ describe("VaultPopupItemsService", () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter remainingCiphers$ down to search term", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
const searchText = "Login";
|
||||
|
||||
searchService.searchCiphers.mockImplementation(async () => {
|
||||
return cipherList.filter((cipher) => {
|
||||
return cipher.name.includes(searchText);
|
||||
});
|
||||
});
|
||||
|
||||
service.remainingCiphers$.subscribe((ciphers) => {
|
||||
// There are 6 remaining ciphers but only 2 with "Login" in the name
|
||||
expect(ciphers.length).toBe(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyVault$", () => {
|
||||
it("should return true if there are no ciphers", (done) => {
|
||||
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable();
|
||||
service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
|
||||
service = new VaultPopupItemsService(
|
||||
cipherServiceMock,
|
||||
vaultSettingsServiceMock,
|
||||
searchService,
|
||||
);
|
||||
service.emptyVault$.subscribe((empty) => {
|
||||
expect(empty).toBe(true);
|
||||
done();
|
||||
@@ -192,6 +268,54 @@ describe("VaultPopupItemsService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("noFilteredResults$", () => {
|
||||
it("should return false when filteredResults has values", (done) => {
|
||||
service.noFilteredResults$.subscribe((noResults) => {
|
||||
expect(noResults).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true when there are zero filteredResults", (done) => {
|
||||
searchService.searchCiphers.mockImplementation(async () => []);
|
||||
service.noFilteredResults$.subscribe((noResults) => {
|
||||
expect(noResults).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFilterApplied$", () => {
|
||||
it("should return true if the search term provided is searchable", (done) => {
|
||||
searchService.isSearchable.mockImplementation(async () => true);
|
||||
service.hasFilterApplied$.subscribe((canSearch) => {
|
||||
expect(canSearch).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if the search term provided is not searchable", (done) => {
|
||||
searchService.isSearchable.mockImplementation(async () => false);
|
||||
service.hasFilterApplied$.subscribe((canSearch) => {
|
||||
expect(canSearch).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFilter", () => {
|
||||
it("should call search Service with the new search term", (done) => {
|
||||
const searchText = "Hello";
|
||||
service.applyFilter(searchText);
|
||||
const searchServiceSpy = jest.spyOn(searchService, "searchCiphers");
|
||||
|
||||
service.favoriteCiphers$.subscribe(() => {
|
||||
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, null, expect.anything());
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A function to generate a list of ciphers of different types
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
Observable,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -26,6 +28,7 @@ import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
})
|
||||
export class VaultPopupItemsService {
|
||||
private _refreshCurrentTab$ = new Subject<void>();
|
||||
private searchText$ = new BehaviorSubject<string>("");
|
||||
|
||||
/**
|
||||
* Observable that contains the list of other cipher types that should be shown
|
||||
@@ -69,6 +72,13 @@ export class VaultPopupItemsService {
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe(
|
||||
switchMap(([ciphers, searchText]) =>
|
||||
this.searchService.searchCiphers(searchText, null, ciphers),
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
/**
|
||||
* List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities
|
||||
* if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name.
|
||||
@@ -76,7 +86,7 @@ export class VaultPopupItemsService {
|
||||
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
|
||||
*/
|
||||
autoFillCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
this._cipherList$,
|
||||
this._filteredCipherList$,
|
||||
this._otherAutoFillTypes$,
|
||||
this._currentAutofillTab$,
|
||||
]).pipe(
|
||||
@@ -96,7 +106,7 @@ export class VaultPopupItemsService {
|
||||
*/
|
||||
favoriteCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
this.autoFillCiphers$,
|
||||
this._cipherList$,
|
||||
this._filteredCipherList$,
|
||||
]).pipe(
|
||||
map(([autoFillCiphers, ciphers]) =>
|
||||
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
|
||||
@@ -114,7 +124,7 @@ export class VaultPopupItemsService {
|
||||
remainingCiphers$: Observable<CipherView[]> = combineLatest([
|
||||
this.autoFillCiphers$,
|
||||
this.favoriteCiphers$,
|
||||
this._cipherList$,
|
||||
this._filteredCipherList$,
|
||||
]).pipe(
|
||||
map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
|
||||
ciphers.filter(
|
||||
@@ -129,7 +139,9 @@ export class VaultPopupItemsService {
|
||||
* Observable that indicates whether a filter is currently applied to the ciphers.
|
||||
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
|
||||
*/
|
||||
hasFilterApplied$: Observable<boolean> = of(false);
|
||||
hasFilterApplied$: Observable<boolean> = this.searchText$.pipe(
|
||||
switchMap((text) => this.searchService.isSearchable(text)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable that indicates whether autofill is allowed in the current context.
|
||||
@@ -146,11 +158,14 @@ export class VaultPopupItemsService {
|
||||
* Observable that indicates whether there are no ciphers to show with the current filter.
|
||||
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
|
||||
*/
|
||||
noFilteredResults$: Observable<boolean> = of(false);
|
||||
noFilteredResults$: Observable<boolean> = this._filteredCipherList$.pipe(
|
||||
map((ciphers) => !ciphers.length),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private searchService: SearchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -160,6 +175,10 @@ export class VaultPopupItemsService {
|
||||
this._refreshCurrentTab$.next(null);
|
||||
}
|
||||
|
||||
applyFilter(newSearchText: string) {
|
||||
this.searchText$.next(newSearchText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort function for ciphers to be used in the autofill section of the Vault tab.
|
||||
* Sorts by type, then by last used date, and finally by name.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@bitwarden/importer/core": ["../../libs/importer/src"],
|
||||
"@bitwarden/importer/ui": ["../../libs/importer/src/components"],
|
||||
"@bitwarden/platform": ["../../libs/platform/src"],
|
||||
"@bitwarden/send-ui": ["../../libs/tools/send/send-ui/src"],
|
||||
"@bitwarden/vault": ["../../libs/vault/src"]
|
||||
},
|
||||
"useDefineForClassFields": false
|
||||
|
||||
@@ -151,7 +151,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MessageSender,
|
||||
useFactory: (subject: Subject<Message<object>>) =>
|
||||
useFactory: (subject: Subject<Message<Record<string, unknown>>>) =>
|
||||
MessageSender.combine(
|
||||
new ElectronRendererMessageSender(), // Communication with main process
|
||||
new SubjectMessageSender(subject), // Communication with ourself
|
||||
@@ -160,7 +160,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MessageListener,
|
||||
useFactory: (subject: Subject<Message<object>>) =>
|
||||
useFactory: (subject: Subject<Message<Record<string, unknown>>>) =>
|
||||
new MessageListener(
|
||||
merge(
|
||||
subject.asObservable(), // For messages from the same context
|
||||
|
||||
@@ -223,7 +223,7 @@ export class Main {
|
||||
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
|
||||
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService);
|
||||
|
||||
const messageSubject = new Subject<Message<object>>();
|
||||
const messageSubject = new Subject<Message<Record<string, unknown>>>();
|
||||
this.messagingService = MessageSender.combine(
|
||||
new SubjectMessageSender(messageSubject), // For local messages
|
||||
new ElectronMainMessagingService(this.windowMain),
|
||||
|
||||
@@ -140,7 +140,7 @@ export class TrayMain {
|
||||
}
|
||||
|
||||
updateContextMenu() {
|
||||
if (this.contextMenu != null && this.isLinux()) {
|
||||
if (this.tray != null && this.contextMenu != null && this.isLinux()) {
|
||||
this.tray.setContextMenu(this.contextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { MessageSender, CommandDefinition } from "@bitwarden/common/platform/mes
|
||||
import { getCommand } from "@bitwarden/common/platform/messaging/internal";
|
||||
|
||||
export class ElectronRendererMessageSender implements MessageSender {
|
||||
send<T extends object>(
|
||||
send<T extends Record<string, unknown>>(
|
||||
commandDefinition: CommandDefinition<T> | string,
|
||||
payload: object | T = {},
|
||||
payload: Record<string, unknown> | T = {},
|
||||
): void {
|
||||
const command = getCommand(commandDefinition);
|
||||
ipc.platform.sendMessage(Object.assign({}, { command: command }, payload));
|
||||
|
||||
@@ -8,8 +8,8 @@ import { tagAsExternal } from "@bitwarden/common/platform/messaging/internal";
|
||||
* @returns An observable stream of messages.
|
||||
*/
|
||||
export const fromIpcMessaging = () => {
|
||||
return fromEventPattern<Message<object>>(
|
||||
return fromEventPattern<Message<Record<string, unknown>>>(
|
||||
(handler) => ipc.platform.onMessage.addListener(handler),
|
||||
(handler) => ipc.platform.onMessage.removeListener(handler),
|
||||
).pipe(tagAsExternal, share());
|
||||
).pipe(tagAsExternal(), share());
|
||||
};
|
||||
|
||||
@@ -87,7 +87,10 @@ export class ElectronMainMessagingService implements MessageSender {
|
||||
});
|
||||
}
|
||||
|
||||
send<T extends object>(commandDefinition: CommandDefinition<T> | string, arg: T | object = {}) {
|
||||
send<T extends Record<string, unknown>>(
|
||||
commandDefinition: CommandDefinition<T> | string,
|
||||
arg: T | Record<string, unknown> = {},
|
||||
) {
|
||||
const command = getCommand(commandDefinition);
|
||||
const message = Object.assign({}, { command: command }, arg);
|
||||
if (this.windowMain.win != null) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@bitwarden/importer/ui": ["../../libs/importer/src/components"],
|
||||
"@bitwarden/node/*": ["../../libs/node/src/*"],
|
||||
"@bitwarden/platform": ["../../libs/platform/src"],
|
||||
"@bitwarden/send-ui": ["../../libs/tools/send/send-ui/src"],
|
||||
"@bitwarden/vault": ["../../libs/vault/src"]
|
||||
},
|
||||
"useDefineForClassFields": false
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<ng-container bitDialogContent>
|
||||
<app-user-verification-form-input
|
||||
formControlName="secret"
|
||||
ngDefaultControl
|
||||
name="secret"
|
||||
[(invalidSecret)]="invalidSecret"
|
||||
></app-user-verification-form-input>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request
|
||||
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
|
||||
import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -32,6 +33,7 @@ export class TwoFactorVerifyComponent {
|
||||
protected formGroup = new FormGroup({
|
||||
secret: new FormControl<Verification | null>(null),
|
||||
});
|
||||
invalidSecret: boolean = false;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData,
|
||||
@@ -45,23 +47,30 @@ export class TwoFactorVerifyComponent {
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
let hashedSecret: string;
|
||||
this.formPromise = this.userVerificationService
|
||||
.buildRequest(this.formGroup.value.secret)
|
||||
.then((request) => {
|
||||
hashedSecret =
|
||||
this.formGroup.value.secret.type === VerificationType.MasterPassword
|
||||
? request.masterPasswordHash
|
||||
: request.otp;
|
||||
return this.apiCall(request);
|
||||
});
|
||||
try {
|
||||
let hashedSecret: string;
|
||||
this.formPromise = this.userVerificationService
|
||||
.buildRequest(this.formGroup.value.secret)
|
||||
.then((request) => {
|
||||
hashedSecret =
|
||||
this.formGroup.value.secret.type === VerificationType.MasterPassword
|
||||
? request.masterPasswordHash
|
||||
: request.otp;
|
||||
return this.apiCall(request);
|
||||
});
|
||||
|
||||
const response = await this.formPromise;
|
||||
this.dialogRef.close({
|
||||
response: response,
|
||||
secret: hashedSecret,
|
||||
verificationType: this.formGroup.value.secret.type,
|
||||
});
|
||||
const response = await this.formPromise;
|
||||
this.dialogRef.close({
|
||||
response: response,
|
||||
secret: hashedSecret,
|
||||
verificationType: this.formGroup.value.secret.type,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse && e.statusCode === 400) {
|
||||
this.invalidSecret = true;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
get dialogTitle(): string {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@bitwarden/importer/core": ["../../libs/importer/src"],
|
||||
"@bitwarden/importer/ui": ["../../libs/importer/src/components"],
|
||||
"@bitwarden/platform": ["../../libs/platform/src"],
|
||||
"@bitwarden/send-ui": ["../../libs/tools/send/send-ui/src"],
|
||||
"@bitwarden/vault": ["../../libs/vault/src"],
|
||||
"@bitwarden/web-vault/*": ["src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user