diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a7bbca9cf0..4ddbf73088 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -514,7 +514,7 @@ export default class MainBackground { this.apiService, this.fileUploadService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new CollectionService( this.cryptoService, @@ -1177,7 +1177,7 @@ export default class MainBackground { const newActiveUser = await this.stateService.clean({ userId: userId }); if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); } await this.stateEventRunnerService.handleEvent("logout", currentUserId as UserId); diff --git a/apps/browser/src/background/service-factories/search-service.factory.ts b/apps/browser/src/background/service-factories/search-service.factory.ts index 38c7620b5a..aa83d2afd2 100644 --- a/apps/browser/src/background/service-factories/search-service.factory.ts +++ b/apps/browser/src/background/service-factories/search-service.factory.ts @@ -14,12 +14,17 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../platform/background/service-factories/log-service.factory"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../platform/background/service-factories/state-provider.factory"; type SearchServiceFactoryOptions = FactoryOptions; export type SearchServiceInitOptions = SearchServiceFactoryOptions & LogServiceInitOptions & - I18nServiceInitOptions; + I18nServiceInitOptions & + StateProviderInitOptions; export function searchServiceFactory( cache: { searchService?: AbstractSearchService } & CachedServices, @@ -33,6 +38,7 @@ export function searchServiceFactory( new SearchService( await logServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/popup/services/popup-search.service.ts b/apps/browser/src/popup/services/popup-search.service.ts index bc5e565e6c..40e6fd2d96 100644 --- a/apps/browser/src/popup/services/popup-search.service.ts +++ b/apps/browser/src/popup/services/popup-search.service.ts @@ -1,17 +1,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { SearchService } from "@bitwarden/common/services/search.service"; export class PopupSearchService extends SearchService { - constructor( - private mainSearchService: SearchService, - logService: LogService, - i18nService: I18nService, - ) { - super(logService, i18nService); + constructor(logService: LogService, i18nService: I18nService, stateProvider: StateProvider) { + super(logService, i18nService, stateProvider); } - clearIndex() { + clearIndex(): Promise { throw new Error("Not available."); } @@ -19,7 +16,7 @@ export class PopupSearchService extends SearchService { throw new Error("Not available."); } - getIndexForSearch() { - return this.mainSearchService.getIndexForSearch(); + async getIndexForSearch() { + return await super.getIndexForSearch(); } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 037246d3c4..1d42381c1e 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -74,17 +74,15 @@ import { GlobalStateProvider, StateProvider, } from "@bitwarden/common/platform/state"; -import { SearchService } from "@bitwarden/common/services/search.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { CipherFileUploadService } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService } from "@bitwarden/components"; -import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; @@ -187,19 +185,8 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: SearchServiceAbstraction, - useFactory: (logService: LogService, i18nService: I18nServiceAbstraction) => { - return new PopupSearchService( - getBgService("searchService")(), - logService, - i18nService, - ); - }, - deps: [LogService, I18nServiceAbstraction], - }), - safeProvider({ - provide: CipherFileUploadService, - useFactory: getBgService("cipherFileUploadService"), - deps: [], + useClass: PopupSearchService, + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: CipherService, @@ -231,11 +218,6 @@ const safeProviders: SafeProvider[] = [ useClass: BrowserEnvironmentService, deps: [LogService, StateProvider, AccountServiceAbstraction], }), - safeProvider({ - provide: TotpService, - useFactory: getBgService("totpService"), - deps: [], - }), safeProvider({ provide: I18nServiceAbstraction, useFactory: (globalStateProvider: GlobalStateProvider) => { @@ -252,6 +234,11 @@ const safeProviders: SafeProvider[] = [ }, deps: [EncryptService], }), + safeProvider({ + provide: TotpServiceAbstraction, + useClass: TotpService, + deps: [CryptoFunctionService, LogService], + }), safeProvider({ provide: AuthRequestServiceAbstraction, useFactory: getBgService("authRequestService"), @@ -333,11 +320,6 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, ], }), - safeProvider({ - provide: VaultExportServiceAbstraction, - useFactory: getBgService("exportService"), - deps: [], - }), safeProvider({ provide: KeyConnectorService, useFactory: getBgService("keyConnectorService"), diff --git a/apps/browser/src/tools/popup/send/send-groupings.component.ts b/apps/browser/src/tools/popup/send/send-groupings.component.ts index 9b3ecc7163..a49773367d 100644 --- a/apps/browser/src/tools/popup/send/send-groupings.component.ts +++ b/apps/browser/src/tools/popup/send/send-groupings.component.ts @@ -171,9 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } private calculateTypeCounts() { diff --git a/apps/browser/src/vault/popup/components/action-buttons.component.ts b/apps/browser/src/vault/popup/components/action-buttons.component.ts index 624789a5c0..b0e7b318d2 100644 --- a/apps/browser/src/vault/popup/components/action-buttons.component.ts +++ b/apps/browser/src/vault/popup/components/action-buttons.component.ts @@ -6,7 +6,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -31,7 +31,7 @@ export class ActionButtonsComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private eventCollectionService: EventCollectionService, - private totpService: TotpService, + private totpService: TotpServiceAbstraction, private passwordRepromptService: PasswordRepromptService, private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 81d1b88fd8..323d2ab4f2 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -311,7 +311,7 @@ export class Fido2Component implements OnInit, OnDestroy { } protected async search() { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = await this.searchService.isSearchable(this.searchText); this.searchPending = true; if (this.hasSearched) { this.displayedCiphers = await this.searchService.searchCiphers( diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index d9cf6550fa..dd1b6790de 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom } from "rxjs"; -import { debounceTime, takeUntil } from "rxjs/operators"; +import { Subject, firstValueFrom, from } from "rxjs"; +import { debounceTime, switchMap, takeUntil } from "rxjs/operators"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -120,8 +120,14 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } this.search$ - .pipe(debounceTime(500), takeUntil(this.destroy$)) - .subscribe(() => this.searchVault()); + .pipe( + debounceTime(500), + switchMap(() => { + return from(this.searchVault()); + }), + takeUntil(this.destroy$), + ) + .subscribe(); const autofillOnPageLoadOrgPolicy = await firstValueFrom( this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$, @@ -232,14 +238,12 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } } - searchVault() { - if (!this.searchService.isSearchable(this.searchText)) { + async searchVault() { + if (!(await this.searchService.isSearchable(this.searchText))) { return; } - // 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.router.navigate(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); + await this.router.navigate(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); } closeOnEsc(e: KeyboardEvent) { diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 2510e2f966..deb4434df4 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -1,8 +1,8 @@ import { Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -53,7 +53,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { folderCounts = new Map(); collectionCounts = new Map(); typeCounts = new Map(); - searchText: string; state: BrowserGroupingsComponentState; showLeftHeader = true; searchPending = false; @@ -71,6 +70,16 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private hasSearched = false; private hasLoadedAllCiphers = false; private allCiphers: CipherView[] = null; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearchable: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(value: string) { + this._searchText$.next(value); + } constructor( private i18nService: I18nService, @@ -148,6 +157,15 @@ export class VaultFilterComponent implements OnInit, OnDestroy { BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -161,6 +179,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.saveState(); this.broadcasterService.unsubscribe(ComponentId); + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -181,7 +201,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { async loadCiphers() { this.allCiphers = await this.cipherService.getAllDecrypted(); if (!this.hasLoadedAllCiphers) { - this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText); + this.hasLoadedAllCiphers = !(await this.searchService.isSearchable(this.searchText)); } await this.search(null); this.getCounts(); @@ -210,7 +230,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } const filterDeleted = (c: CipherView) => !c.isDeleted; if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.ciphers = await this.searchService.searchCiphers( this.searchText, filterDeleted, @@ -223,7 +243,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; if (!this.hasLoadedAllCiphers && !this.hasSearched) { await this.loadCiphers(); } else { @@ -381,9 +401,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } closeOnEsc(e: KeyboardEvent) { diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index d7ef15afb7..a225db0c11 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -20,7 +20,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; @@ -74,7 +74,7 @@ export class ViewComponent extends BaseViewComponent { constructor( cipherService: CipherService, folderService: FolderService, - totpService: TotpService, + totpService: TotpServiceAbstraction, tokenService: TokenService, i18nService: I18nService, cryptoService: CryptoService, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index a2e4afe709..fd6552e2f0 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -414,7 +414,7 @@ export class Main { this.sendService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.broadcasterService = new BroadcasterService(); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index b0b411c5f0..257921e2ad 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -609,7 +609,7 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up if (userBeingLoggedOut === preLogoutActiveUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( diff --git a/apps/web/src/app/admin-console/common/base.people.component.ts b/apps/web/src/app/admin-console/common/base.people.component.ts index 0a1f4338ff..fbb9faf569 100644 --- a/apps/web/src/app/admin-console/common/base.people.component.ts +++ b/apps/web/src/app/admin-console/common/base.people.component.ts @@ -1,5 +1,5 @@ -import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { Directive, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -32,8 +32,10 @@ const MaxCheckedCount = 500; @Directive() export abstract class BasePeopleComponent< - UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, -> { + UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, + > + implements OnInit, OnDestroy +{ @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) confirmModalRef: ViewContainerRef; @@ -88,7 +90,6 @@ export abstract class BasePeopleComponent< status: StatusType; users: UserType[] = []; pagedUsers: UserType[] = []; - searchText: string; actionPromise: Promise; protected allUsers: UserType[] = []; @@ -97,7 +98,19 @@ export abstract class BasePeopleComponent< protected didScroll = false; protected pageSize = 100; + protected destroy$ = new Subject(); + private pagedUsersCount = 0; + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } constructor( protected apiService: ApiService, @@ -122,6 +135,22 @@ export abstract class BasePeopleComponent< abstract reinviteUser(id: string): Promise; abstract confirmUser(user: UserType, publicKey: Uint8Array): Promise; + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { const response = await this.getUsers(); this.statusMap.clear(); @@ -390,12 +419,8 @@ export abstract class BasePeopleComponent< } } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { // 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 diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index a41d57f874..9ff596181e 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -91,15 +91,16 @@ export class GroupsComponent implements OnInit, OnDestroy { private pagedGroupsCount = 0; private pagedGroups: GroupDetailsRow[]; private searchedGroups: GroupDetailsRow[]; - private _searchText: string; + private _searchText$ = new BehaviorSubject(""); private destroy$ = new Subject(); private refreshGroups$ = new BehaviorSubject(null); + private isSearching: boolean = false; get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); // Manually update as we are not using the search pipe in the template this.updateSearchedGroups(); } @@ -114,7 +115,7 @@ export class GroupsComponent implements OnInit, OnDestroy { if (this.isPaging()) { return this.pagedGroups; } - if (this.isSearching()) { + if (this.isSearching) { return this.searchedGroups; } return this.groups; @@ -180,6 +181,15 @@ export class GroupsComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe(); + + this._searchText$ + .pipe( + switchMap((searchText) => this.searchService.isSearchable(searchText)), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); } ngOnDestroy() { @@ -297,10 +307,6 @@ export class GroupsComponent implements OnInit, OnDestroy { this.loadMore(); } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - check(groupRow: GroupDetailsRow) { groupRow.checked = !groupRow.checked; } @@ -310,7 +316,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { this.resetPaging(); } @@ -340,7 +346,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } private updateSearchedGroups() { - if (this.searchService.isSearchable(this.searchText)) { + if (this.isSearching) { // Making use of the pipe in the component as we need know which groups where filtered this.searchedGroups = this.searchPipe.transform( this.groups, diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 0da0ab79f0..6b632dce38 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, @@ -9,7 +9,6 @@ import { map, Observable, shareReplay, - Subject, switchMap, takeUntil, } from "rxjs"; @@ -73,10 +72,7 @@ import { ResetPasswordComponent } from "./components/reset-password.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -export class PeopleComponent - extends BasePeopleComponent - implements OnInit, OnDestroy -{ +export class PeopleComponent extends BasePeopleComponent { @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) @@ -99,7 +95,6 @@ export class PeopleComponent orgResetPasswordPolicyEnabled = false; protected canUseSecretsManager$: Observable; - private destroy$ = new Subject(); constructor( apiService: ApiService, @@ -210,8 +205,7 @@ export class PeopleComponent } ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); + super.ngOnDestroy(); } async load() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 628875f04a..7a3b34969a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -281,7 +281,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.stateEventRunnerService.handleEvent("logout", userId as UserId); - this.searchService.clearIndex(); + await this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 2027b0102b..6fe31f29f4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -272,7 +272,7 @@ export class VaultComponent implements OnInit, OnDestroy { concatMap(async ([ciphers, filter, searchText]) => { const filterFunction = createFilterFunction(filter); - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); } @@ -283,7 +283,7 @@ export class VaultComponent implements OnInit, OnDestroy { const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter, searchText]) => { + concatMap(async ([collections, filter, searchText]) => { if (filter.collectionId === undefined || filter.collectionId === Unassigned) { return []; } @@ -303,7 +303,7 @@ export class VaultComponent implements OnInit, OnDestroy { collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; } - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { collectionsToReturn = this.searchPipe.transform( collectionsToReturn, searchText, diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index cb01951fcc..50d3216150 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -331,7 +331,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - this.searchService.indexCiphers(ciphers, organization.id); + await this.searchService.indexCiphers(ciphers, organization.id); return ciphers; }), ); @@ -350,7 +350,7 @@ export class VaultComponent implements OnInit, OnDestroy { const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter, searchText]) => { + concatMap(async ([collections, filter, searchText]) => { if ( filter.collectionId === Unassigned || (filter.collectionId === undefined && filter.type !== undefined) @@ -369,7 +369,7 @@ export class VaultComponent implements OnInit, OnDestroy { collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; } - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { collectionsToReturn = this.searchPipe.transform( collectionsToReturn, searchText, @@ -436,7 +436,7 @@ export class VaultComponent implements OnInit, OnDestroy { const filterFunction = createFilterFunction(filter); - if (this.searchService.isSearchable(searchText)) { + if (await this.searchService.isSearchable(searchText)) { return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index 72cce7aac3..abdfd6deff 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -39,7 +39,6 @@ const DisallowedPlanTypes = [ // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class ClientsComponent implements OnInit { providerId: string; - searchText: string; addableOrganizations: Organization[]; loading = true; manageOrganizations = false; @@ -57,6 +56,17 @@ export class ClientsComponent implements OnInit { FeatureFlag.EnableConsolidatedBilling, false, ); + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } constructor( private route: ActivatedRoute, @@ -77,27 +87,41 @@ export class ClientsComponent implements OnInit { ) {} async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); if (enableConsolidatedBilling) { await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route }); } else { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; + this.route.parent.params + .pipe( + switchMap((params) => { + this.providerId = params.providerId; + return from(this.load()); + }), + takeUntil(this.destroy$), + ) + .subscribe(); - await this.load(); - - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; - }); + this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { + this.searchText = qParams.search; }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); } } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { const response = await this.apiService.getProviderClients(this.providerId); this.clients = response.data != null && response.data.length > 0 ? response.data : []; @@ -118,20 +142,14 @@ export class ClientsComponent implements OnInit { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { - // 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.resetPaging(); } return !searching && this.clients && this.clients.length > this.pageSize; } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - - async resetPaging() { + resetPaging() { this.pagedClients = []; this.loadMore(); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts index b83daf24b5..8688373ff7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; @@ -34,10 +34,7 @@ import { UserAddEditComponent } from "./user-add-edit.component"; templateUrl: "people.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PeopleComponent - extends BasePeopleComponent - implements OnInit -{ +export class PeopleComponent extends BasePeopleComponent { @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; @@ -119,6 +116,10 @@ export class PeopleComponent }); } + ngOnDestroy(): void { + super.ngOnDestroy(); + } + getUsers(): Promise> { return this.apiService.getProviderUsers(this.providerId); } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts index 79dd25e891..a9f341be94 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -1,8 +1,8 @@ import { SelectionModel } from "@angular/cdk/collections"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -23,12 +23,22 @@ import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-o }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class ManageClientOrganizationsComponent implements OnInit { +export class ManageClientOrganizationsComponent implements OnInit, OnDestroy { providerId: string; loading = true; manageOrganizations = false; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(search: string) { + this._searchText$.value; + this.selection.clear(); this.dataSource.filter = search; } @@ -67,6 +77,20 @@ export class ManageClientOrganizationsComponent implements OnInit { this.searchText = qParams.search; }); }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -80,7 +104,7 @@ export class ManageClientOrganizationsComponent implements OnInit { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { // 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 @@ -89,10 +113,6 @@ export class ManageClientOrganizationsComponent implements OnInit { return !searching && this.clients && this.clients.length > this.pageSize; } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - async resetPaging() { this.pagedClients = []; this.loadMore(); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index ce60271e27..79bb6714d0 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -726,7 +726,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SearchServiceAbstraction, useClass: SearchService, - deps: [LogService, I18nServiceAbstraction], + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: NotificationsServiceAbstraction, diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index 90d9b39e8c..fc51e32416 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,5 +1,13 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + Subject, + firstValueFrom, + mergeMap, + from, + switchMap, + takeUntil, +} from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -24,7 +32,6 @@ export class SendComponent implements OnInit, OnDestroy { expired = false; type: SendType = null; sends: SendView[] = []; - searchText: string; selectedType: SendType; selectedAll: boolean; filter: (cipher: SendView) => boolean; @@ -39,6 +46,8 @@ export class SendComponent implements OnInit, OnDestroy { private searchTimeout: any; private destroy$ = new Subject(); private _filteredSends: SendView[]; + private _searchText$ = new BehaviorSubject(""); + protected isSearchable: boolean = false; get filteredSends(): SendView[] { return this._filteredSends; @@ -48,6 +57,14 @@ export class SendComponent implements OnInit, OnDestroy { this._filteredSends = filteredSends; } + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } + constructor( protected sendService: SendService, protected i18nService: I18nService, @@ -68,6 +85,15 @@ export class SendComponent implements OnInit, OnDestroy { .subscribe((policyAppliesToActiveUser) => { this.disableSend = policyAppliesToActiveUser; }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -122,14 +148,14 @@ export class SendComponent implements OnInit, OnDestroy { clearTimeout(this.searchTimeout); } if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); return; } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); this.searchPending = false; diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cdfb1b6299..458b10865c 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,4 +1,5 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { BehaviorSubject, Subject, from, switchMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -6,7 +7,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @Directive() -export class VaultItemsComponent { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; @Output() onCipherClicked = new EventEmitter(); @Output() onCipherRightClicked = new EventEmitter(); @@ -23,13 +24,15 @@ export class VaultItemsComponent { protected searchPending = false; + private destroy$ = new Subject(); private searchTimeout: any = null; - private _searchText: string = null; + private isSearchable: boolean = false; + private _searchText$ = new BehaviorSubject(""); get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); } constructor( @@ -37,6 +40,21 @@ export class VaultItemsComponent { protected cipherService: CipherService, ) {} + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); + } + + ngOnDestroy(): void { + throw new Error("Method not implemented."); + } + async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); @@ -90,7 +108,7 @@ export class VaultItemsComponent { } isSearching() { - return !this.searchPending && this.searchService.isSearchable(this.searchText); + return !this.searchPending && this.isSearchable; } protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; diff --git a/libs/common/src/abstractions/search.service.ts b/libs/common/src/abstractions/search.service.ts index 97a12c8315..dfcf2c5d07 100644 --- a/libs/common/src/abstractions/search.service.ts +++ b/libs/common/src/abstractions/search.service.ts @@ -1,11 +1,15 @@ +import { Observable } from "rxjs"; + import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { CipherView } from "../vault/models/view/cipher.view"; export abstract class SearchService { - indexedEntityId?: string = null; - clearIndex: () => void; - isSearchable: (query: string) => boolean; - indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => void; + indexedEntityId$: Observable; + + clearIndex: () => Promise; + isSearchable: (query: string) => Promise; + indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise; searchCiphers: ( query: string, filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 10c2f3d36d..b6855c5271 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -127,3 +127,4 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", web: "disk-local", }); export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); +export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory"); diff --git a/libs/common/src/services/search.service.ts b/libs/common/src/services/search.service.ts index 773d51297a..429992b076 100644 --- a/libs/common/src/services/search.service.ts +++ b/libs/common/src/services/search.service.ts @@ -1,20 +1,91 @@ import * as lunr from "lunr"; +import { Observable, firstValueFrom, map } from "rxjs"; +import { Jsonify } from "type-fest"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; import { UriMatchStrategy } from "../models/domain/domain-service"; import { I18nService } from "../platform/abstractions/i18n.service"; import { LogService } from "../platform/abstractions/log.service"; +import { + ActiveUserState, + StateProvider, + UserKeyDefinition, + VAULT_SEARCH_MEMORY, +} from "../platform/state"; import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { FieldType } from "../vault/enums"; import { CipherType } from "../vault/enums/cipher-type"; import { CipherView } from "../vault/models/view/cipher.view"; +export type SerializedLunrIndex = { + version: string; + fields: string[]; + fieldVectors: [string, number[]]; + invertedIndex: any[]; + pipeline: string[]; +}; + +/** + * The `KeyDefinition` for accessing the search index in application state. + * The key definition is configured to clear the index when the user locks the vault. + */ +export const LUNR_SEARCH_INDEX = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndex", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + +/** + * The `KeyDefinition` for accessing the ID of the entity currently indexed by Lunr search. + * The key definition is configured to clear the indexed entity ID when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXED_ENTITY_ID = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndexedEntityId", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + +/** + * The `KeyDefinition` for accessing the state of Lunr search indexing, indicating whether the Lunr search index is currently being built or updating. + * The key definition is configured to clear the indexing state when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXING = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "isIndexing", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock"], + }, +); + export class SearchService implements SearchServiceAbstraction { private static registeredPipeline = false; - indexedEntityId?: string = null; - private indexing = false; - private index: lunr.Index = null; + private searchIndexState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEX); + private readonly index$: Observable = this.searchIndexState.state$.pipe( + map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)), + ); + + private searchIndexEntityIdState: ActiveUserState = this.stateProvider.getActive( + LUNR_SEARCH_INDEXED_ENTITY_ID, + ); + readonly indexedEntityId$: Observable = + this.searchIndexEntityIdState.state$.pipe(map((id) => id)); + + private searchIsIndexingState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEXING); + private readonly searchIsIndexing$: Observable = this.searchIsIndexingState.state$.pipe( + map((indexing) => indexing ?? false), + ); + private readonly immediateSearchLocales: string[] = ["zh-CN", "zh-TW", "ja", "ko", "vi"]; private readonly defaultSearchableMinLength: number = 2; private searchableMinLength: number = this.defaultSearchableMinLength; @@ -22,6 +93,7 @@ export class SearchService implements SearchServiceAbstraction { constructor( private logService: LogService, private i18nService: I18nService, + private stateProvider: StateProvider, ) { this.i18nService.locale$.subscribe((locale) => { if (this.immediateSearchLocales.indexOf(locale) !== -1) { @@ -40,28 +112,29 @@ export class SearchService implements SearchServiceAbstraction { } } - clearIndex(): void { - this.indexedEntityId = null; - this.index = null; + async clearIndex(): Promise { + await this.searchIndexEntityIdState.update(() => null); + await this.searchIndexState.update(() => null); + await this.searchIsIndexingState.update(() => null); } - isSearchable(query: string): boolean { + async isSearchable(query: string): Promise { query = SearchService.normalizeSearchQuery(query); + const index = await this.getIndexForSearch(); const notSearchable = query == null || - (this.index == null && query.length < this.searchableMinLength) || - (this.index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); + (index == null && query.length < this.searchableMinLength) || + (index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); return !notSearchable; } - indexCiphers(ciphers: CipherView[], indexedEntityId?: string): void { - if (this.indexing) { + async indexCiphers(ciphers: CipherView[], indexedEntityId?: string): Promise { + if (await this.getIsIndexing()) { return; } - this.indexing = true; - this.indexedEntityId = indexedEntityId; - this.index = null; + await this.setIsIndexing(true); + await this.setIndexedEntityIdForSearch(indexedEntityId as IndexedEntityId); const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); builder.ref("id"); @@ -95,9 +168,11 @@ export class SearchService implements SearchServiceAbstraction { builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId }); ciphers = ciphers || []; ciphers.forEach((c) => builder.add(c)); - this.index = builder.build(); + const index = builder.build(); - this.indexing = false; + await this.setIndexForSearch(index.toJSON() as SerializedLunrIndex); + + await this.setIsIndexing(false); this.logService.info("Finished search indexing"); } @@ -125,18 +200,18 @@ export class SearchService implements SearchServiceAbstraction { ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); } - if (!this.isSearchable(query)) { + if (!(await this.isSearchable(query))) { return ciphers; } - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 250)); - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 500)); } } - const index = this.getIndexForSearch(); + const index = await this.getIndexForSearch(); if (index == null) { // Fall back to basic search if index is not available return this.searchCiphersBasic(ciphers, query); @@ -230,8 +305,24 @@ export class SearchService implements SearchServiceAbstraction { return sendsMatched.concat(lowPriorityMatched); } - getIndexForSearch(): lunr.Index { - return this.index; + async getIndexForSearch(): Promise { + return await firstValueFrom(this.index$); + } + + private async setIndexForSearch(index: SerializedLunrIndex): Promise { + await this.searchIndexState.update(() => index); + } + + private async setIndexedEntityIdForSearch(indexedEntityId: IndexedEntityId): Promise { + await this.searchIndexEntityIdState.update(() => indexedEntityId); + } + + private async setIsIndexing(indexing: boolean): Promise { + await this.searchIsIndexingState.update(() => indexing); + } + + private async getIsIndexing(): Promise { + return await firstValueFrom(this.searchIsIndexing$); } private fieldExtractor(c: CipherView, joined: boolean) { diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index 72252036c8..35faf0fcee 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -91,7 +91,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { const currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); await this.folderService.clearCache(); await this.collectionService.clearActiveUserCache(); } diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 714f5dffc3..97c87e684e 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -8,3 +8,4 @@ export type CollectionId = Opaque; export type ProviderId = Opaque; export type PolicyId = Opaque; export type CipherId = Opaque; +export type IndexedEntityId = Opaque; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 4a6e96ead7..7d3772f8c5 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -89,9 +89,9 @@ export class CipherService implements CipherServiceAbstraction { } if (this.searchService != null) { if (value == null) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); } else { - this.searchService.indexCiphers(value); + await this.searchService.indexCiphers(value); } } } @@ -333,9 +333,10 @@ export class CipherService implements CipherServiceAbstraction { private async reindexCiphers() { const userId = await this.stateService.getUserId(); const reindexRequired = - this.searchService != null && (this.searchService.indexedEntityId ?? userId) !== userId; + this.searchService != null && + ((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId; if (reindexRequired) { - this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); + await this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); } }