diff --git a/.eslintrc.json b/.eslintrc.json index 2b52485689a..ce9c4a33426 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -218,6 +218,12 @@ "no-restricted-imports": ["error", { "patterns": ["@bitwarden/platform/*", "src/**/*"] }] } }, + { + "files": ["libs/tools/send/send-ui/src/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/send-ui/*", "src/**/*"] }] + } + }, { "files": ["libs/vault/src/**/*.ts"], "rules": { diff --git a/.github/whitelist-capital-letters.txt b/.github/whitelist-capital-letters.txt index 99ce80376d3..45d9df649bc 100644 --- a/.github/whitelist-capital-letters.txt +++ b/.github/whitelist-capital-letters.txt @@ -21,6 +21,7 @@ ./libs/platform/README.md ./libs/tools/README.md ./libs/tools/export/vault-export/README.md +./libs/tools/send/README.md ./libs/vault/README.md ./README.md ./LICENSE_BITWARDEN.txt diff --git a/apps/browser/package.json b/apps/browser/package.json index 1f54bd64aca..25912c4832f 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -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", diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 578f988846c..8d05645b3a6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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>; + intraprocessMessagingSubject: Subject>>; 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>(); + this.intraprocessMessagingSubject = new Subject>>(); 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, @@ -1171,7 +1176,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((resolve) => { setTimeout(async () => { diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index bb6853d6d63..70f3d9b6ff5 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -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.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index ec7e9ed5ab7..687166ec79b 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -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.", diff --git a/apps/browser/src/platform/messaging/chrome-message.sender.ts b/apps/browser/src/platform/messaging/chrome-message.sender.ts index 0e57ecfb4ec..914b8fd43a4 100644 --- a/apps/browser/src/platform/messaging/chrome-message.sender.ts +++ b/apps/browser/src/platform/messaging/chrome-message.sender.ts @@ -15,9 +15,9 @@ const HANDLED_ERRORS: Record = { export class ChromeMessageSender implements MessageSender { constructor(private readonly logService: LogService) {} - send( + send>( commandDefinition: string | CommandDefinition, - payload: object | T = {}, + payload: Record | T = {}, ): void { const command = getCommand(commandDefinition); chrome.runtime.sendMessage(Object.assign(payload, { command: command }), () => { diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts new file mode 100644 index 00000000000..a9ee7c23b9c --- /dev/null +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -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(); + const folderService = mock(); + const folderApiService = mock(); + const messageSender = mock(); + const logService = mock(); + const cipherService = mock(); + const collectionService = mock(); + const apiService = mock(); + const accountService = mock(); + const authService = mock(); + const sendService = mock(); + const sendApiService = mock(); + const messageListener = mock(); + + 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) => { + 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(); + 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(); + 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(); + 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); + }); + }); +}); diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index 3c144316724..0a2c7074298 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -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("doFullSync"); export class ForegroundSyncService extends CoreSyncService { constructor( @@ -59,18 +60,29 @@ export class ForegroundSyncService extends CoreSyncService { async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise { 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; diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts new file mode 100644 index 00000000000..51f97e9f879 --- /dev/null +++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts @@ -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(); + const messageListener = mock(); + const messageSender = mock(); + const logService = mock(); + + const messages = new Subject(); + 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", + }); + }); + }); +}); diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts index b9e18accacd..079edbf4c71 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.ts @@ -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( + "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 { + 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, + }); + } } } diff --git a/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts b/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts index e30f35b680b..ebc01ad86fa 100644 --- a/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts +++ b/apps/browser/src/platform/utils/from-chrome-runtime-messaging.ts @@ -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>>(), share(), ); }; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 342b3d26a66..ace9af3dfa8 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -524,7 +524,7 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: MessageListener, - useFactory: (subject: Subject>, ngZone: NgZone) => + useFactory: (subject: Subject>>, 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>, logService: LogService) => + useFactory: (subject: Subject>>, 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>(); + return new Subject>>(); } }, deps: [], }), safeProvider({ provide: MessageSender, - useFactory: (subject: Subject>, logService: LogService) => + useFactory: (subject: Subject>>, 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>(); + return new Subject>>(); } }, deps: [], diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html new file mode 100644 index 00000000000..55674aa83e5 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts new file mode 100644 index 00000000000..321717285a9 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -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(); + + private searchText$ = new Subject(); + + constructor() { + this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) + .subscribe((data) => { + this.searchTextChanged.emit(data); + }); + } + + onSearchTextChanged() { + this.searchText$.next(this.searchText); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index df2b2c1a13e..7d83d9f26cc 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -24,6 +24,9 @@ + + +
{ const cipherServiceMock = mock(); const vaultSettingsServiceMock = mock(); + const searchService = mock(); 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 diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 52de117e6b5..9a66ada08c5 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -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(); + private searchText$ = new BehaviorSubject(""); /** * 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 = combineLatest([ - this._cipherList$, + this._filteredCipherList$, this._otherAutoFillTypes$, this._currentAutofillTab$, ]).pipe( @@ -96,7 +106,7 @@ export class VaultPopupItemsService { */ favoriteCiphers$: Observable = 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 = 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 = of(false); + hasFilterApplied$: Observable = 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 = of(false); + noFilteredResults$: Observable = 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. diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index e1bf2b7211c..eb2c02fd3fd 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -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 diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 8d800970535..25d4df5f935 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -151,7 +151,7 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: MessageSender, - useFactory: (subject: Subject>) => + useFactory: (subject: Subject>>) => 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>) => + useFactory: (subject: Subject>>) => new MessageListener( merge( subject.asObservable(), // For messages from the same context diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 63d6e062a1e..d30d6ad821b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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>(); + const messageSubject = new Subject>>(); this.messagingService = MessageSender.combine( new SubjectMessageSender(messageSubject), // For local messages new ElectronMainMessagingService(this.windowMain), diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index 948c48f519a..8450a653222 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -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); } } diff --git a/apps/desktop/src/platform/services/electron-renderer-message.sender.ts b/apps/desktop/src/platform/services/electron-renderer-message.sender.ts index 037c303b3b6..109a52a8d8f 100644 --- a/apps/desktop/src/platform/services/electron-renderer-message.sender.ts +++ b/apps/desktop/src/platform/services/electron-renderer-message.sender.ts @@ -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( + send>( commandDefinition: CommandDefinition | string, - payload: object | T = {}, + payload: Record | T = {}, ): void { const command = getCommand(commandDefinition); ipc.platform.sendMessage(Object.assign({}, { command: command }, payload)); diff --git a/apps/desktop/src/platform/utils/from-ipc-messaging.ts b/apps/desktop/src/platform/utils/from-ipc-messaging.ts index 254a215ceb3..cdefbf5c506 100644 --- a/apps/desktop/src/platform/utils/from-ipc-messaging.ts +++ b/apps/desktop/src/platform/utils/from-ipc-messaging.ts @@ -8,8 +8,8 @@ import { tagAsExternal } from "@bitwarden/common/platform/messaging/internal"; * @returns An observable stream of messages. */ export const fromIpcMessaging = () => { - return fromEventPattern>( + return fromEventPattern>>( (handler) => ipc.platform.onMessage.addListener(handler), (handler) => ipc.platform.onMessage.removeListener(handler), - ).pipe(tagAsExternal, share()); + ).pipe(tagAsExternal(), share()); }; diff --git a/apps/desktop/src/services/electron-main-messaging.service.ts b/apps/desktop/src/services/electron-main-messaging.service.ts index ce4ffd903a8..150890bf56f 100644 --- a/apps/desktop/src/services/electron-main-messaging.service.ts +++ b/apps/desktop/src/services/electron-main-messaging.service.ts @@ -87,7 +87,10 @@ export class ElectronMainMessagingService implements MessageSender { }); } - send(commandDefinition: CommandDefinition | string, arg: T | object = {}) { + send>( + commandDefinition: CommandDefinition | string, + arg: T | Record = {}, + ) { const command = getCommand(commandDefinition); const message = Object.assign({}, { command: command }, arg); if (this.windowMain.win != null) { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index a62d494f294..eb054ba80ba 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -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 diff --git a/apps/web/src/app/auth/settings/two-factor-verify.component.html b/apps/web/src/app/auth/settings/two-factor-verify.component.html index 283282ccb8e..288a096c23f 100644 --- a/apps/web/src/app/auth/settings/two-factor-verify.component.html +++ b/apps/web/src/app/auth/settings/two-factor-verify.component.html @@ -7,8 +7,8 @@ diff --git a/apps/web/src/app/auth/settings/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor-verify.component.ts index 7dc2847b828..d41efc9b027 100644 --- a/apps/web/src/app/auth/settings/two-factor-verify.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-verify.component.ts @@ -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(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 { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 07d244ee602..f6ea012c368 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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/*"] } diff --git a/bitwarden_license/bit-common/tsconfig.json b/bitwarden_license/bit-common/tsconfig.json index 6b40d447419..afe66845c6a 100644 --- a/bitwarden_license/bit-common/tsconfig.json +++ b/bitwarden_license/bit-common/tsconfig.json @@ -15,6 +15,7 @@ "../../libs/tools/export/vault-export/vault-export-core/src" ], "@bitwarden/vault-export-ui": ["../../libs/tools/export/vault-export/vault-export-core/src"], + "@bitwarden/send-ui": ["../../libs/tools/send/send-ui/src"], "@bitwarden/platform": ["../../libs/platform/src"], "@bitwarden/vault": ["../../libs/vault/src"], "@bitwarden/web-vault/*": ["../../apps/web/src/*"], diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 27095e1da91..05517df57c8 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -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/*": ["../../apps/web/src/*"], diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 9a94659e69f..17a98498d68 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -49,7 +49,7 @@ export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken("DEFAULT_VAULT_TIMEOUT"); -export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken>>( - "INTRAPROCESS_MESSAGING_SUBJECT", -); +export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken< + Subject>> +>("INTRAPROCESS_MESSAGING_SUBJECT"); export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1f7b714fc85..60f83934af7 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -649,7 +649,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: BroadcasterService, useClass: DefaultBroadcasterService, - deps: [MessageSender, MessageListener], + deps: [MessageListener], }), safeProvider({ provide: VaultTimeoutSettingsServiceAbstraction, @@ -1165,17 +1165,19 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: INTRAPROCESS_MESSAGING_SUBJECT, - useFactory: () => new Subject>(), + useFactory: () => new Subject>>(), deps: [], }), safeProvider({ provide: MessageListener, - useFactory: (subject: Subject>) => new MessageListener(subject.asObservable()), + useFactory: (subject: Subject>>) => + new MessageListener(subject.asObservable()), deps: [INTRAPROCESS_MESSAGING_SUBJECT], }), safeProvider({ provide: MessageSender, - useFactory: (subject: Subject>) => new SubjectMessageSender(subject), + useFactory: (subject: Subject>>) => + new SubjectMessageSender(subject), deps: [INTRAPROCESS_MESSAGING_SUBJECT], }), safeProvider({ diff --git a/libs/common/src/platform/abstractions/broadcaster.service.ts b/libs/common/src/platform/abstractions/broadcaster.service.ts index 8abfb5a90c5..3afa25be90a 100644 --- a/libs/common/src/platform/abstractions/broadcaster.service.ts +++ b/libs/common/src/platform/abstractions/broadcaster.service.ts @@ -6,10 +6,6 @@ export interface MessageBase { * @deprecated Use the observable from the appropriate service instead. */ export abstract class BroadcasterService { - /** - * @deprecated Use the observable from the appropriate service instead. - */ - abstract send(message: MessageBase, id?: string): void; /** * @deprecated Use the observable from the appropriate service instead. */ diff --git a/libs/common/src/platform/messaging/helpers.spec.ts b/libs/common/src/platform/messaging/helpers.spec.ts index fcd36b44111..8839a542ffc 100644 --- a/libs/common/src/platform/messaging/helpers.spec.ts +++ b/libs/common/src/platform/messaging/helpers.spec.ts @@ -12,7 +12,7 @@ describe("helpers", () => { }); it("can get the command from a message definition", () => { - const commandDefinition = new CommandDefinition("myCommand"); + const commandDefinition = new CommandDefinition>("myCommand"); const command = getCommand(commandDefinition); @@ -22,9 +22,9 @@ describe("helpers", () => { describe("tag integration", () => { it("can tag and identify as tagged", async () => { - const messagesSubject = new Subject>(); + const messagesSubject = new Subject>>(); - const taggedMessages = messagesSubject.asObservable().pipe(tagAsExternal); + const taggedMessages = messagesSubject.asObservable().pipe(tagAsExternal()); const firstValuePromise = firstValueFrom(taggedMessages); @@ -39,7 +39,7 @@ describe("helpers", () => { describe("isExternalMessage", () => { it.each([null, { command: "myCommand", test: "object" }, undefined] as Message< Record - >[])("returns false when value is %s", (value: Message) => { + >[])("returns false when value is %s", (value: Message>) => { expect(isExternalMessage(value)).toBe(false); }); }); diff --git a/libs/common/src/platform/messaging/helpers.ts b/libs/common/src/platform/messaging/helpers.ts index ba772e517bc..e7521ea42a2 100644 --- a/libs/common/src/platform/messaging/helpers.ts +++ b/libs/common/src/platform/messaging/helpers.ts @@ -1,8 +1,10 @@ -import { MonoTypeOperatorFunction, map } from "rxjs"; +import { map } from "rxjs"; -import { Message, CommandDefinition } from "./types"; +import { CommandDefinition } from "./types"; -export const getCommand = (commandDefinition: CommandDefinition | string) => { +export const getCommand = ( + commandDefinition: CommandDefinition> | string, +) => { if (typeof commandDefinition === "string") { return commandDefinition; } else { @@ -16,8 +18,8 @@ export const isExternalMessage = (message: Record) => { return message?.[EXTERNAL_SOURCE_TAG] === true; }; -export const tagAsExternal: MonoTypeOperatorFunction> = map( - (message: Message) => { +export const tagAsExternal = >() => { + return map((message: T) => { return Object.assign(message, { [EXTERNAL_SOURCE_TAG]: true }); - }, -); + }); +}; diff --git a/libs/common/src/platform/messaging/message.listener.ts b/libs/common/src/platform/messaging/message.listener.ts index df453c84226..8936fe97528 100644 --- a/libs/common/src/platform/messaging/message.listener.ts +++ b/libs/common/src/platform/messaging/message.listener.ts @@ -11,7 +11,7 @@ import { Message, CommandDefinition } from "./types"; * or vault data changes and those observables should be preferred over messaging. */ export class MessageListener { - constructor(private readonly messageStream: Observable>) {} + constructor(private readonly messageStream: Observable>>) {} /** * A stream of all messages sent through the application. It does not contain type information for the @@ -28,7 +28,9 @@ export class MessageListener { * * @param commandDefinition The CommandDefinition containing the information about the message type you care about. */ - messages$(commandDefinition: CommandDefinition): Observable { + messages$>( + commandDefinition: CommandDefinition, + ): Observable { return this.allMessages$.pipe( filter((msg) => msg?.command === commandDefinition.command), ) as Observable; diff --git a/libs/common/src/platform/messaging/message.sender.ts b/libs/common/src/platform/messaging/message.sender.ts index 6bf26615807..fc8ad450746 100644 --- a/libs/common/src/platform/messaging/message.sender.ts +++ b/libs/common/src/platform/messaging/message.sender.ts @@ -3,9 +3,9 @@ import { CommandDefinition } from "./types"; class MultiMessageSender implements MessageSender { constructor(private readonly innerMessageSenders: MessageSender[]) {} - send( + send>( commandDefinition: string | CommandDefinition, - payload: object | T = {}, + payload: Record | T = {}, ): void { for (const messageSender of this.innerMessageSenders) { messageSender.send(commandDefinition, payload); @@ -26,7 +26,10 @@ export abstract class MessageSender { * @param commandDefinition * @param payload */ - abstract send(commandDefinition: CommandDefinition, payload: T): void; + abstract send>( + commandDefinition: CommandDefinition, + payload: T, + ): void; /** * A legacy method for sending messages in a non-type safe way. @@ -38,12 +41,12 @@ export abstract class MessageSender { * @param payload Extra contextual information regarding the message. Be aware that this payload may * be serialized and lose all prototype information. */ - abstract send(command: string, payload?: object): void; + abstract send(command: string, payload?: Record): void; /** Implementation of the other two overloads, read their docs instead. */ - abstract send( + abstract send>( commandDefinition: CommandDefinition | string, - payload: T | object, + payload: T | Record, ): void; /** diff --git a/libs/common/src/platform/messaging/subject-message.sender.ts b/libs/common/src/platform/messaging/subject-message.sender.ts index 94ae6f27f3c..170f8a24c6f 100644 --- a/libs/common/src/platform/messaging/subject-message.sender.ts +++ b/libs/common/src/platform/messaging/subject-message.sender.ts @@ -5,11 +5,11 @@ import { MessageSender } from "./message.sender"; import { Message, CommandDefinition } from "./types"; export class SubjectMessageSender implements MessageSender { - constructor(private readonly messagesSubject: Subject>) {} + constructor(private readonly messagesSubject: Subject>>) {} - send( + send>( commandDefinition: string | CommandDefinition, - payload: object | T = {}, + payload: Record | T = {}, ): void { const command = getCommand(commandDefinition); this.messagesSubject.next(Object.assign(payload ?? {}, { command: command })); diff --git a/libs/common/src/platform/messaging/types.ts b/libs/common/src/platform/messaging/types.ts index f30163344fd..0461132a0a8 100644 --- a/libs/common/src/platform/messaging/types.ts +++ b/libs/common/src/platform/messaging/types.ts @@ -5,9 +5,9 @@ declare const tag: unique symbol; * alonside `MessageSender` and `MessageListener` for providing a type * safe(-ish) way of sending and receiving messages. */ -export class CommandDefinition { +export class CommandDefinition> { [tag]: T; constructor(readonly command: string) {} } -export type Message = { command: string } & T; +export type Message> = { command: string } & T; diff --git a/libs/common/src/platform/services/default-broadcaster.service.ts b/libs/common/src/platform/services/default-broadcaster.service.ts index a16745c643d..6ec4f2be953 100644 --- a/libs/common/src/platform/services/default-broadcaster.service.ts +++ b/libs/common/src/platform/services/default-broadcaster.service.ts @@ -1,7 +1,7 @@ import { Subscription } from "rxjs"; import { BroadcasterService, MessageBase } from "../abstractions/broadcaster.service"; -import { MessageListener, MessageSender } from "../messaging"; +import { MessageListener } from "../messaging"; /** * Temporary implementation that just delegates to the message sender and message listener @@ -10,14 +10,7 @@ import { MessageListener, MessageSender } from "../messaging"; export class DefaultBroadcasterService implements BroadcasterService { subscriptions = new Map(); - constructor( - private readonly messageSender: MessageSender, - private readonly messageListener: MessageListener, - ) {} - - send(message: MessageBase, id?: string) { - this.messageSender.send(message?.command, message); - } + constructor(private readonly messageListener: MessageListener) {} subscribe(id: string, messageCallback: (message: MessageBase) => void) { this.subscriptions.set( diff --git a/libs/importer/src/importers/lastpass/access/services/parser.ts b/libs/importer/src/importers/lastpass/access/services/parser.ts index 83d56832290..19ef55bc739 100644 --- a/libs/importer/src/importers/lastpass/access/services/parser.ts +++ b/libs/importer/src/importers/lastpass/access/services/parser.ts @@ -33,6 +33,7 @@ export class Parser { options: ParserOptions, ): Promise { let id: string; + let step = 0; try { const placeholder = "decryption failed"; const reader = new BinaryReader(chunk.payload); @@ -42,6 +43,7 @@ export class Parser { id = Utils.fromBufferToUtf8(this.readItem(reader)); // 1: name + step = 1; const name = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -49,6 +51,7 @@ export class Parser { ); // 2: group + step = 2; const group = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -56,6 +59,7 @@ export class Parser { ); // 3: url + step = 3; let url = Utils.fromBufferToUtf8( this.decodeHexLoose(Utils.fromBufferToUtf8(this.readItem(reader))), ); @@ -66,6 +70,7 @@ export class Parser { } // 4: extra (notes) + step = 4; const notes = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -73,12 +78,14 @@ export class Parser { ); // 5: fav (is favorite) + step = 5; const isFavorite = Utils.fromBufferToUtf8(this.readItem(reader)) === "1"; // 6: sharedfromaid (?) this.skipItem(reader); // 7: username + step = 7; let username = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -86,6 +93,7 @@ export class Parser { ); // 8: password + step = 8; let password = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -99,6 +107,7 @@ export class Parser { this.skipItem(reader); // 11: sn (is secure note) + step = 11; const isSecureNote = Utils.fromBufferToUtf8(this.readItem(reader)) === "1"; // Parse secure note @@ -214,6 +223,7 @@ export class Parser { this.skipItem(reader); // 39: totp (?) + step = 39; const totp = await this.cryptoUtils.decryptAes256PlainWithDefault( this.readItem(reader), encryptionKey, @@ -227,6 +237,7 @@ export class Parser { // 42: last_credential_monitoring_stat (?) // Adjust the path to include the group and the shared folder, if any. + step = 42; const path = this.makeAccountPath(group, folder); const account = new Account(); @@ -243,7 +254,12 @@ export class Parser { return account; } catch (err) { throw new Error( - "Error parsing accounts on item with ID:" + id + " errorMessage: " + err.message, + "Error parsing accounts on item with ID:" + + id + + " step #" + + step + + " errorMessage: " + + err.message, ); } } diff --git a/libs/shared/tsconfig.libs.json b/libs/shared/tsconfig.libs.json index 452a565c9e4..517b2a4d164 100644 --- a/libs/shared/tsconfig.libs.json +++ b/libs/shared/tsconfig.libs.json @@ -15,6 +15,7 @@ "@bitwarden/importer/core": ["../importer/src"], "@bitwarden/importer/ui": ["../importer/src/components"], "@bitwarden/platform": ["../platform/src"], + "@bitwarden/send-ui": ["../tools/send/send-ui/src"], "@bitwarden/node/*": ["../node/src/*"], "@bitwarden/vault": ["../vault/src"] } diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html new file mode 100644 index 00000000000..8abc0c77553 --- /dev/null +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -0,0 +1,100 @@ + + {{ "personalVaultExportPolicyInEffect" | i18n }} + + + +
+ + + {{ "exportFrom" | i18n }} + + + + + + + + + {{ "fileFormat" | i18n }} + + + + + + + + {{ "exportTypeHeading" | i18n }} + + + {{ "accountRestricted" | i18n }} + {{ "accountRestrictedOptionDescription" | i18n }} + + + + {{ "passwordProtected" | i18n }} + {{ "passwordProtectedOptionDescription" | i18n }} + + + + +
+ + {{ "filePassword" | i18n }} + + + {{ "exportPasswordDescription" | i18n }} + + +
+ + {{ "confirmFilePassword" | i18n }} + + + +
+
+
diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 3e091a2417a..d90e0690154 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -1,7 +1,9 @@ -import { Directive, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from "@angular/core"; -import { UntypedFormBuilder, Validators } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from "@angular/core"; +import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms"; import { map, merge, Observable, startWith, Subject, takeUntil } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -16,11 +18,70 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncryptedExportType } from "@bitwarden/common/tools/enums/encrypted-export-type.enum"; -import { DialogService } from "@bitwarden/components"; +import { + AsyncActionsModule, + BitSubmitDirective, + ButtonModule, + CalloutModule, + DialogService, + FormFieldModule, + IconButtonModule, + RadioButtonModule, + SelectModule, +} from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; -@Directive() +import { ExportScopeCalloutComponent } from "./export-scope-callout.component"; + +@Component({ + selector: "tools-export", + templateUrl: "export.component.html", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + SelectModule, + CalloutModule, + RadioButtonModule, + ExportScopeCalloutComponent, + UserVerificationDialogComponent, + ], +}) export class ExportComponent implements OnInit, OnDestroy { + /** + * The hosting control also needs a bitSubmitDirective (on the Submit button) which calls this components {@link submit}-method. + * This components formState (loading/disabled) is emitted back up to the hosting component so for example the Submit button can be enabled/disabled and show loading state. + */ + @ViewChild(BitSubmitDirective) + private bitSubmit: BitSubmitDirective; + + /** + * Emits true when the BitSubmitDirective({@link bitSubmit} is executing {@link submit} and false when execution has completed. + * Example: Used to show the loading state of the submit button present on the hosting component + * */ + @Output() + formLoading = new EventEmitter(); + + /** + * Emits true when this form gets disabled and false when enabled. + * Example: Used to disable the submit button, which is present on the hosting component + * */ + @Output() + formDisabled = new EventEmitter(); + + /** + * Emits when the creation and download of the export-file have succeeded + * - Emits an null/empty string when exporting from an individual vault + * - Emits the organizationId when exporting from an organizationl vault + * */ + @Output() + onSuccessfulExport = new EventEmitter(); + @Output() onSaved = new EventEmitter(); @ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent; @@ -74,6 +135,11 @@ export class ExportComponent implements OnInit, OnDestroy { ) {} async ngOnInit() { + // Setup subscription to emit when this form is enabled/disabled + this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => { + this.formDisabled.emit(c === "DISABLED"); + }); + this.policyService .policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport) .pipe(takeUntil(this.destroy$)) @@ -88,8 +154,7 @@ export class ExportComponent implements OnInit, OnDestroy { this.exportForm.get("format").valueChanges, this.exportForm.get("fileEncryptionType").valueChanges, ) - .pipe(takeUntil(this.destroy$)) - .pipe(startWith(0)) + .pipe(startWith(0), takeUntil(this.destroy$)) .subscribe(() => this.adjustValidators()); if (this.organizationId) { @@ -118,6 +183,12 @@ export class ExportComponent implements OnInit, OnDestroy { this.exportForm.controls.vaultSelector.setValue("myVault"); } + ngAfterViewInit(): void { + this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + this.formLoading.emit(loading); + }); + } + ngOnDestroy(): void { this.destroy$.next(); } @@ -187,6 +258,7 @@ export class ExportComponent implements OnInit, OnDestroy { protected saved() { this.onSaved.emit(); + this.onSuccessfulExport.emit(this.organizationId); } private async verifyUser(): Promise { @@ -235,6 +307,10 @@ export class ExportComponent implements OnInit, OnDestroy { } protected getFileName(prefix?: string) { + if (this.organizationId) { + prefix = "org"; + } + let extension = this.format; if (this.format === "encrypted_json") { if (prefix == null) { @@ -248,7 +324,15 @@ export class ExportComponent implements OnInit, OnDestroy { } protected async collectEvent(): Promise { - await this.eventCollectionService.collect(EventType.User_ClientExportedVault); + if (this.organizationId) { + return await this.eventCollectionService.collect( + EventType.Organization_ClientExportedVault, + null, + false, + this.organizationId, + ); + } + return await this.eventCollectionService.collect(EventType.User_ClientExportedVault); } get format() { diff --git a/libs/tools/send/README.md b/libs/tools/send/README.md new file mode 100644 index 00000000000..a8bd7029830 --- /dev/null +++ b/libs/tools/send/README.md @@ -0,0 +1,17 @@ +# Bitwarden Send + +This folder contains 2 packages that can be used to create and modify Sends. + +## semd-core + +Package name: `@bitwarden/send-core` + +Contains all types, models, and services for Bitwarden Send + +Currently in use by the Bitwarden Web Vault, CLI, desktop app and browser extension + +## send-ui + +Package name: `@bitwarden/send-ui` + +Contains all UI components used for Bitwarden Send diff --git a/libs/tools/send/send-ui/jest.config.js b/libs/tools/send/send-ui/jest.config.js new file mode 100644 index 00000000000..100075fc7a7 --- /dev/null +++ b/libs/tools/send/send-ui/jest.config.js @@ -0,0 +1,13 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../../shared/tsconfig.libs"); + +/** @type {import('jest').Config} */ +module.exports = { + testMatch: ["**/+(*.)+(spec).+(ts)"], + preset: "ts-jest", + testEnvironment: "jsdom", + moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/../../../", + }), +}; diff --git a/libs/tools/send/send-ui/package.json b/libs/tools/send/send-ui/package.json new file mode 100644 index 00000000000..81bbbde4358 --- /dev/null +++ b/libs/tools/send/send-ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "@bitwarden/send-ui", + "version": "0.0.0", + "description": "Angular components for Bitwarden Send", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch" + }, + "dependencies": { + "@bitwarden/common": "file:../../../common" + } +} diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/libs/tools/send/send-ui/tsconfig.json b/libs/tools/send/send-ui/tsconfig.json new file mode 100644 index 00000000000..c52bfd7b0df --- /dev/null +++ b/libs/tools/send/send-ui/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../shared/tsconfig.libs", + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/tools/send/send-ui/tsconfig.spec.json b/libs/tools/send/send-ui/tsconfig.spec.json new file mode 100644 index 00000000000..fc8520e7376 --- /dev/null +++ b/libs/tools/send/send-ui/tsconfig.spec.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/package-lock.json b/package-lock.json index 18a7dad0293..03a14daa6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -193,7 +193,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.5.0" + "version": "2024.5.1" }, "apps/cli": { "name": "@bitwarden/cli", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 7d4aee3fb36..ccb483f9b6f 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -25,6 +25,7 @@ "@bitwarden/vault-export-ui": [".libs/tools/export/vault-export/vault-export-ui/src"], "@bitwarden/importer/core": ["./libs/importer/src"], "@bitwarden/importer/ui": ["./libs/importer/src/components"], + "@bitwarden/send-ui": [".libs/tools/send/send-ui/src"], "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/vault": ["./libs/vault/src"], diff --git a/tsconfig.json b/tsconfig.json index 0a519a5b7e4..6b9e3d960ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,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/node/*": ["./libs/node/src/*"], "@bitwarden/web-vault/*": ["./apps/web/src/*"], "@bitwarden/vault": ["./libs/vault/src"],