1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 15:33:55 +00:00

[PM-12045] search service activeuserstate (#13035)

* removing activeuserstate from search service
This commit is contained in:
Jason Ng
2025-03-06 12:26:24 -05:00
committed by GitHub
parent 9761588a2a
commit f65daf7284
19 changed files with 159 additions and 637 deletions

View File

@@ -389,11 +389,14 @@ export class Fido2Component implements OnInit, OnDestroy {
}
protected async search() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.hasSearched = true;
const isSearchable = await this.searchService.isSearchable(this.searchText);
const isSearchable = await this.searchService.isSearchable(userId, this.searchText);
if (isSearchable) {
this.displayedCiphers = await this.searchService.searchCiphers(
userId,
this.searchText,
null,
this.ciphers,

View File

@@ -87,7 +87,7 @@ describe("VaultPopupItemsService", () => {
failedToDecryptCiphersSubject.asObservable(),
);
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
searchService.searchCiphers.mockImplementation(async (userId, _, __, ciphers) => ciphers);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(c.id)),
);
@@ -276,7 +276,7 @@ describe("VaultPopupItemsService", () => {
it("should filter autoFillCiphers$ down to search term", (done) => {
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => {
searchService.searchCiphers.mockImplementation(async (userId, q, _, ciphers) => {
return ciphers.filter((cipher) => {
return cipher.name.includes(searchText);
});
@@ -472,7 +472,12 @@ describe("VaultPopupItemsService", () => {
service.applyFilter(searchText);
service.favoriteCiphers$.subscribe(() => {
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, undefined, expect.anything());
expect(searchServiceSpy).toHaveBeenCalledWith(
"UserId",
searchText,
undefined,
expect.anything(),
);
done();
});
});

View File

@@ -23,6 +23,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -138,22 +139,29 @@ export class VaultPopupItemsService {
* Observable that indicates whether there is search text present that is searchable.
* @private
*/
private _hasSearchText = this.searchText$.pipe(
switchMap((searchText) => this.searchService.isSearchable(searchText)),
private _hasSearchText = combineLatest([
this.searchText$,
getUserId(this.accountService.activeAccount$),
]).pipe(
switchMap(([searchText, userId]) => {
return this.searchService.isSearchable(userId, searchText);
}),
);
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._activeCipherList$,
this.searchText$,
this.vaultPopupListFiltersService.filterFunction$,
getUserId(this.accountService.activeAccount$),
]).pipe(
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
map(([ciphers, searchText, filterFunction, userId]): [CipherView[], string, UserId] => [
filterFunction(ciphers),
searchText,
userId,
]),
switchMap(
([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, undefined, ciphers) as Promise<
([ciphers, searchText, userId]) =>
this.searchService.searchCiphers(userId, searchText, undefined, ciphers) as Promise<
PopupCipherView[]
>,
),

View File

@@ -5,6 +5,7 @@ import { Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -52,6 +53,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
sendApiService: SendApiService,
dialogService: DialogService,
toastService: ToastService,
accountService: AccountService,
) {
super(
sendService,
@@ -65,6 +67,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
sendApiService,
dialogService,
toastService,
accountService,
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.searchText$.subscribe((searchText) => {

View File

@@ -1,425 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, ViewChild, ViewContainerRef } from "@angular/core";
import { FormControl } from "@angular/forms";
import { firstValueFrom, concatMap, map, lastValueFrom, startWith, debounceTime } from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import {
OrganizationUserStatusType,
OrganizationUserType,
ProviderUserStatusType,
ProviderUserType,
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
const MaxCheckedCount = 500;
@Directive()
export abstract class BasePeopleComponent<
UserType extends ProviderUserUserDetailsResponse | OrganizationUserView,
> {
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
confirmModalRef: ViewContainerRef;
get allCount() {
return this.activeUsers != null ? this.activeUsers.length : 0;
}
get invitedCount() {
return this.statusMap.has(this.userStatusType.Invited)
? this.statusMap.get(this.userStatusType.Invited).length
: 0;
}
get acceptedCount() {
return this.statusMap.has(this.userStatusType.Accepted)
? this.statusMap.get(this.userStatusType.Accepted).length
: 0;
}
get confirmedCount() {
return this.statusMap.has(this.userStatusType.Confirmed)
? this.statusMap.get(this.userStatusType.Confirmed).length
: 0;
}
get revokedCount() {
return this.statusMap.has(this.userStatusType.Revoked)
? this.statusMap.get(this.userStatusType.Revoked).length
: 0;
}
get showConfirmUsers(): boolean {
return (
this.activeUsers != null &&
this.statusMap != null &&
this.activeUsers.length > 1 &&
this.confirmedCount > 0 &&
this.confirmedCount < 3 &&
this.acceptedCount > 0
);
}
get showBulkConfirmUsers(): boolean {
return this.acceptedCount > 0;
}
abstract userType: typeof OrganizationUserType | typeof ProviderUserType;
abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType;
loading = true;
statusMap = new Map<StatusType, UserType[]>();
status: StatusType;
users: UserType[] = [];
pagedUsers: UserType[] = [];
actionPromise: Promise<void>;
protected allUsers: UserType[] = [];
protected activeUsers: UserType[] = [];
protected didScroll = false;
protected pageSize = 100;
protected searchControl = new FormControl("", { nonNullable: true });
protected isSearching$ = this.searchControl.valueChanges.pipe(
debounceTime(500),
concatMap((searchText) => this.searchService.isSearchable(searchText)),
startWith(false),
);
protected isPaging$ = this.isSearching$.pipe(
map((isSearching) => {
if (isSearching && this.didScroll) {
this.resetPaging();
}
return !isSearching && this.users && this.users.length > this.pageSize;
}),
);
private pagedUsersCount = 0;
constructor(
protected apiService: ApiService,
private searchService: SearchService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected keyService: KeyService,
protected validationService: ValidationService,
private logService: LogService,
private searchPipe: SearchPipe,
protected userNamePipe: UserNamePipe,
protected dialogService: DialogService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
protected toastService: ToastService,
) {}
abstract edit(user: UserType): void;
abstract getUsers(): Promise<ListResponse<UserType> | UserType[]>;
abstract deleteUser(id: string): Promise<void>;
abstract revokeUser(id: string): Promise<void>;
abstract restoreUser(id: string): Promise<void>;
abstract reinviteUser(id: string): Promise<void>;
abstract confirmUser(user: UserType, publicKey: Uint8Array): Promise<void>;
async load() {
const response = await this.getUsers();
this.statusMap.clear();
this.activeUsers = [];
for (const status of Utils.iterateEnum(this.userStatusType)) {
this.statusMap.set(status, []);
}
if (response instanceof ListResponse) {
this.allUsers = response.data != null && response.data.length > 0 ? response.data : [];
} else if (Array.isArray(response)) {
this.allUsers = response;
}
this.allUsers.sort(
Utils.getSortFunction<ProviderUserUserDetailsResponse | OrganizationUserView>(
this.i18nService,
"email",
),
);
this.allUsers.forEach((u) => {
if (!this.statusMap.has(u.status)) {
this.statusMap.set(u.status, [u]);
} else {
this.statusMap.get(u.status).push(u);
}
if (u.status !== this.userStatusType.Revoked) {
this.activeUsers.push(u);
}
});
this.filter(this.status);
this.loading = false;
}
filter(status: StatusType) {
this.status = status;
if (this.status != null) {
this.users = this.statusMap.get(this.status);
} else {
this.users = this.activeUsers;
}
// Reset checkbox selecton
this.selectAll(false);
this.resetPaging();
}
loadMore() {
if (!this.users || this.users.length <= this.pageSize) {
return;
}
const pagedLength = this.pagedUsers.length;
let pagedSize = this.pageSize;
if (pagedLength === 0 && this.pagedUsersCount > this.pageSize) {
pagedSize = this.pagedUsersCount;
}
if (this.users.length > pagedLength) {
this.pagedUsers = this.pagedUsers.concat(
this.users.slice(pagedLength, pagedLength + pagedSize),
);
}
this.pagedUsersCount = this.pagedUsers.length;
this.didScroll = this.pagedUsers.length > this.pageSize;
}
checkUser(user: UserType, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
}
selectAll(select: boolean) {
if (select) {
this.selectAll(false);
}
const filteredUsers = this.searchPipe.transform(
this.users,
this.searchControl.value,
"name",
"email",
"id",
);
const selectCount =
select && filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
}
}
resetPaging() {
this.pagedUsers = [];
this.loadMore();
}
invite() {
this.edit(null);
}
protected async removeUserConfirmationDialog(user: UserType) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
content: { key: "removeUserConfirmation" },
type: "warning",
});
}
async remove(user: UserType) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.deleteUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
});
this.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
protected async revokeUserConfirmationDialog(user: UserType) {
return this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(),
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
}
async revoke(user: UserType) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async restore(user: UserType) {
this.actionPromise = this.restoreUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async reinvite(user: UserType) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.reinviteUser(user.id);
try {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
});
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async confirm(user: UserType) {
function updateUser(self: BasePeopleComponent<UserType>) {
user.status = self.userStatusType.Confirmed;
const mapIndex = self.statusMap.get(self.userStatusType.Accepted).indexOf(user);
if (mapIndex > -1) {
self.statusMap.get(self.userStatusType.Accepted).splice(mapIndex, 1);
self.statusMap.get(self.userStatusType.Confirmed).push(user);
}
}
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey);
await this.actionPromise;
updateUser(this);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
});
} catch (e) {
this.validationService.showError(e);
throw e;
} finally {
this.actionPromise = null;
}
};
if (this.actionPromise != null) {
return;
}
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user != null ? user.userId : null,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},
});
await lastValueFrom(dialogRef.closed);
return;
}
try {
const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey);
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
} catch (e) {
this.logService.error(e);
}
await confirmUser(publicKey);
} catch (e) {
this.logService.error(`Handled exception: ${e}`);
}
}
protected revokeWarningMessage(): string {
return this.i18nService.t("revokeUserConfirmation");
}
protected getCheckedUsers() {
return this.users.filter((u) => (u as any).checked);
}
protected removeUser(user: UserType) {
let index = this.users.indexOf(user);
if (index > -1) {
this.users.splice(index, 1);
this.resetPaging();
}
index = this.allUsers.indexOf(user);
if (index > -1) {
this.allUsers.splice(index, 1);
}
if (this.statusMap.has(user.status)) {
index = this.statusMap.get(user.status).indexOf(user);
if (index > -1) {
this.statusMap.get(user.status).splice(index, 1);
}
}
}
}

View File

@@ -178,6 +178,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected freeTrial$: Observable<FreeTrial>;
protected resellerWarning$: Observable<ResellerWarning | null>;
protected prevCipherId: string | null = null;
protected userId: UserId;
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
@@ -258,6 +259,8 @@ export class VaultComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
@@ -401,7 +404,7 @@ export class VaultComponent implements OnInit, OnDestroy {
ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id);
}
await this.searchService.indexCiphers(ciphers, organization.id);
await this.searchService.indexCiphers(this.userId, ciphers, organization.id);
return ciphers;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
@@ -445,7 +448,7 @@ export class VaultComponent implements OnInit, OnDestroy {
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
}
if (await this.searchService.isSearchable(searchText)) {
if (await this.searchService.isSearchable(this.userId, searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,
@@ -519,8 +522,13 @@ export class VaultComponent implements OnInit, OnDestroy {
const filterFunction = createFilterFunction(filter);
if (await this.searchService.isSearchable(searchText)) {
return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers);
if (await this.searchService.isSearchable(this.userId, searchText)) {
return await this.searchService.searchCiphers(
this.userId,
searchText,
[filterFunction],
ciphers,
);
}
return ciphers.filter(filterFunction);

View File

@@ -309,7 +309,7 @@ export class AppComponent implements OnDestroy, OnInit {
await this.stateEventRunnerService.handleEvent("logout", userId);
await this.searchService.clearIndex();
await this.searchService.clearIndex(userId);
this.authService.logOut(async () => {
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);

View File

@@ -7,6 +7,7 @@ import { lastValueFrom } from "rxjs";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -74,6 +75,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
dialogService: DialogService,
toastService: ToastService,
private addEditFormConfigService: DefaultSendFormConfigService,
accountService: AccountService,
) {
super(
sendService,
@@ -87,6 +89,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
sendApiService,
dialogService,
toastService,
accountService,
);
}

View File

@@ -348,8 +348,13 @@ export class VaultComponent implements OnInit, OnDestroy {
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];
if (await this.searchService.isSearchable(searchText)) {
return await this.searchService.searchCiphers(searchText, [filterFunction], allCiphers);
if (await this.searchService.isSearchable(activeUserId, searchText)) {
return await this.searchService.searchCiphers(
activeUserId,
searchText,
[filterFunction],
allCiphers,
);
}
return allCiphers.filter(filterFunction);
@@ -378,7 +383,7 @@ export class VaultComponent implements OnInit, OnDestroy {
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
}
if (await this.searchService.isSearchable(searchText)) {
if (await this.searchService.isSearchable(activeUserId, searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,

View File

@@ -1,132 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { SelectionModel } from "@angular/cdk/collections";
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, from, Subject, switchMap } from "rxjs";
import { first, takeUntil } from "rxjs/operators";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import { WebProviderService } from "../services/web-provider.service";
@Directive()
export abstract class BaseClientsComponent implements OnInit, OnDestroy {
protected destroy$ = new Subject<void>();
private searchText$ = new BehaviorSubject<string>("");
get searchText() {
return this.searchText$.value;
}
set searchText(value: string) {
this.searchText$.next(value);
this.selection.clear();
this.dataSource.filter = value;
}
private searching = false;
protected scrolled = false;
protected pageSize = 100;
private pagedClientsCount = 0;
protected selection = new SelectionModel<string>(true, []);
protected clients: ProviderOrganizationOrganizationDetailsResponse[];
protected pagedClients: ProviderOrganizationOrganizationDetailsResponse[];
protected dataSource = new TableDataSource<ProviderOrganizationOrganizationDetailsResponse>();
abstract providerId: string;
protected constructor(
protected activatedRoute: ActivatedRoute,
protected dialogService: DialogService,
private i18nService: I18nService,
private searchService: SearchService,
private toastService: ToastService,
private validationService: ValidationService,
private webProviderService: WebProviderService,
) {}
abstract load(): Promise<void>;
ngOnInit() {
this.activatedRoute.queryParams
.pipe(first(), takeUntil(this.destroy$))
.subscribe((queryParams) => {
this.searchText = queryParams.search;
});
this.searchText$
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(searchText))),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
this.searching = isSearchable;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
isPaging() {
if (this.searching && this.scrolled) {
this.resetPaging();
}
return !this.searching && this.clients && this.clients.length > this.pageSize;
}
resetPaging() {
this.pagedClients = [];
this.loadMore();
}
loadMore() {
if (!this.clients || this.clients.length <= this.pageSize) {
return;
}
const pagedLength = this.pagedClients.length;
let pagedSize = this.pageSize;
if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) {
pagedSize = this.pagedClientsCount;
}
if (this.clients.length > pagedLength) {
this.pagedClients = this.pagedClients.concat(
this.clients.slice(pagedLength, pagedLength + pagedSize),
);
}
this.pagedClientsCount = this.pagedClients.length;
this.scrolled = this.pagedClients.length > this.pageSize;
}
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
const confirmed = await this.dialogService.openSimpleDialog({
title: organization.organizationName,
content: { key: "detachOrganizationConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.webProviderService.detachOrganization(this.providerId, organization.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("detachedOrganization", organization.organizationName),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
}
}

View File

@@ -14,6 +14,8 @@ import {
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -79,9 +81,12 @@ export class SendComponent implements OnInit, OnDestroy {
protected sendApiService: SendApiService,
protected dialogService: DialogService,
protected toastService: ToastService,
private accountService: AccountService,
) {}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntil(this.destroy$))
@@ -91,7 +96,7 @@ export class SendComponent implements OnInit, OnDestroy {
this._searchText$
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(searchText))),
switchMap((searchText) => from(this.searchService.isSearchable(userId, searchText))),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {

View File

@@ -27,6 +27,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected searchPending = false;
private userId: UserId;
private destroy$ = new Subject<void>();
private searchTimeout: any = null;
private isSearchable: boolean = false;
@@ -44,10 +45,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected accountService: AccountService,
) {}
ngOnInit(): void {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this._searchText$
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(searchText))),
switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
@@ -133,6 +136,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
}
this.ciphers = await this.searchService.searchCiphers(
this.userId,
this.searchText,
[this.filter, this.deletedFilter],
indexedCiphers,

View File

@@ -3,16 +3,21 @@
import { Observable } from "rxjs";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId } from "../types/guid";
import { IndexedEntityId, UserId } from "../types/guid";
import { CipherView } from "../vault/models/view/cipher.view";
export abstract class SearchService {
indexedEntityId$: Observable<IndexedEntityId | null>;
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
clearIndex: () => Promise<void>;
isSearchable: (query: string) => Promise<boolean>;
indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise<void>;
clearIndex: (userId: UserId) => Promise<void>;
isSearchable: (userId: UserId, query: string) => Promise<boolean>;
indexCiphers: (
userId: UserId,
ciphersToIndex: CipherView[],
indexedEntityGuid?: string,
) => Promise<void>;
searchCiphers: (
userId: UserId,
query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
ciphers?: CipherView[],

View File

@@ -138,10 +138,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
if (userId == null || userId === currentUserId) {
await this.searchService.clearIndex();
await this.collectionService.clearActiveUserCache();
}
await this.searchService.clearIndex(lockingUserId);
await this.folderService.clearDecryptedFolderState(lockingUserId);
await this.masterPasswordService.clearMasterKey(lockingUserId);

View File

@@ -9,13 +9,13 @@ import { UriMatchStrategy } from "../models/domain/domain-service";
import { I18nService } from "../platform/abstractions/i18n.service";
import { LogService } from "../platform/abstractions/log.service";
import {
ActiveUserState,
SingleUserState,
StateProvider,
UserKeyDefinition,
VAULT_SEARCH_MEMORY,
} from "../platform/state";
import { SendView } from "../tools/send/models/view/send.view";
import { IndexedEntityId } from "../types/guid";
import { IndexedEntityId, UserId } from "../types/guid";
import { FieldType } from "../vault/enums";
import { CipherType } from "../vault/enums/cipher-type";
import { CipherView } from "../vault/models/view/cipher.view";
@@ -70,24 +70,6 @@ export const LUNR_SEARCH_INDEXING = new UserKeyDefinition<boolean>(
export class SearchService implements SearchServiceAbstraction {
private static registeredPipeline = false;
private searchIndexState: ActiveUserState<SerializedLunrIndex> =
this.stateProvider.getActive(LUNR_SEARCH_INDEX);
private readonly index$: Observable<lunr.Index | null> = this.searchIndexState.state$.pipe(
map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)),
);
private searchIndexEntityIdState: ActiveUserState<IndexedEntityId> = this.stateProvider.getActive(
LUNR_SEARCH_INDEXED_ENTITY_ID,
);
readonly indexedEntityId$: Observable<IndexedEntityId | null> =
this.searchIndexEntityIdState.state$.pipe(map((id) => id));
private searchIsIndexingState: ActiveUserState<boolean> =
this.stateProvider.getActive(LUNR_SEARCH_INDEXING);
private readonly searchIsIndexing$: Observable<boolean> = 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;
@@ -114,15 +96,41 @@ export class SearchService implements SearchServiceAbstraction {
}
}
async clearIndex(): Promise<void> {
await this.searchIndexEntityIdState.update(() => null);
await this.searchIndexState.update(() => null);
await this.searchIsIndexingState.update(() => null);
private searchIndexState(userId: UserId): SingleUserState<SerializedLunrIndex> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEX);
}
async isSearchable(query: string): Promise<boolean> {
private index$(userId: UserId): Observable<lunr.Index | null> {
return this.searchIndexState(userId).state$.pipe(
map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)),
);
}
private searchIndexEntityIdState(userId: UserId): SingleUserState<IndexedEntityId | null> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEXED_ENTITY_ID);
}
indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null> {
return this.searchIndexEntityIdState(userId).state$.pipe(map((id) => id));
}
private searchIsIndexingState(userId: UserId): SingleUserState<boolean> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEXING);
}
private searchIsIndexing$(userId: UserId): Observable<boolean> {
return this.searchIsIndexingState(userId).state$.pipe(map((indexing) => indexing ?? false));
}
async clearIndex(userId: UserId): Promise<void> {
await this.searchIndexEntityIdState(userId).update(() => null);
await this.searchIndexState(userId).update(() => null);
await this.searchIsIndexingState(userId).update(() => null);
}
async isSearchable(userId: UserId, query: string): Promise<boolean> {
query = SearchService.normalizeSearchQuery(query);
const index = await this.getIndexForSearch();
const index = await this.getIndexForSearch(userId);
const notSearchable =
query == null ||
(index == null && query.length < this.searchableMinLength) ||
@@ -130,13 +138,17 @@ export class SearchService implements SearchServiceAbstraction {
return !notSearchable;
}
async indexCiphers(ciphers: CipherView[], indexedEntityId?: string): Promise<void> {
if (await this.getIsIndexing()) {
async indexCiphers(
userId: UserId,
ciphers: CipherView[],
indexedEntityId?: string,
): Promise<void> {
if (await this.getIsIndexing(userId)) {
return;
}
await this.setIsIndexing(true);
await this.setIndexedEntityIdForSearch(indexedEntityId as IndexedEntityId);
await this.setIsIndexing(userId, true);
await this.setIndexedEntityIdForSearch(userId, indexedEntityId as IndexedEntityId);
const builder = new lunr.Builder();
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
builder.ref("id");
@@ -172,14 +184,15 @@ export class SearchService implements SearchServiceAbstraction {
ciphers.forEach((c) => builder.add(c));
const index = builder.build();
await this.setIndexForSearch(index.toJSON() as SerializedLunrIndex);
await this.setIndexForSearch(userId, index.toJSON() as SerializedLunrIndex);
await this.setIsIndexing(false);
await this.setIsIndexing(userId, false);
this.logService.info("Finished search indexing");
}
async searchCiphers(
userId: UserId,
query: string,
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null,
ciphers: CipherView[],
@@ -202,18 +215,18 @@ export class SearchService implements SearchServiceAbstraction {
ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean);
}
if (!(await this.isSearchable(query))) {
if (!(await this.isSearchable(userId, query))) {
return ciphers;
}
if (await this.getIsIndexing()) {
if (await this.getIsIndexing(userId)) {
await new Promise((r) => setTimeout(r, 250));
if (await this.getIsIndexing()) {
if (await this.getIsIndexing(userId)) {
await new Promise((r) => setTimeout(r, 500));
}
}
const index = await this.getIndexForSearch();
const index = await this.getIndexForSearch(userId);
if (index == null) {
// Fall back to basic search if index is not available
return this.searchCiphersBasic(ciphers, query);
@@ -307,24 +320,27 @@ export class SearchService implements SearchServiceAbstraction {
return sendsMatched.concat(lowPriorityMatched);
}
async getIndexForSearch(): Promise<lunr.Index | null> {
return await firstValueFrom(this.index$);
async getIndexForSearch(userId: UserId): Promise<lunr.Index | null> {
return await firstValueFrom(this.index$(userId));
}
private async setIndexForSearch(index: SerializedLunrIndex): Promise<void> {
await this.searchIndexState.update(() => index);
private async setIndexForSearch(userId: UserId, index: SerializedLunrIndex): Promise<void> {
await this.searchIndexState(userId).update(() => index);
}
private async setIndexedEntityIdForSearch(indexedEntityId: IndexedEntityId): Promise<void> {
await this.searchIndexEntityIdState.update(() => indexedEntityId);
private async setIndexedEntityIdForSearch(
userId: UserId,
indexedEntityId: IndexedEntityId,
): Promise<void> {
await this.searchIndexEntityIdState(userId).update(() => indexedEntityId);
}
private async setIsIndexing(indexing: boolean): Promise<void> {
await this.searchIsIndexingState.update(() => indexing);
private async setIsIndexing(userId: UserId, indexing: boolean): Promise<void> {
await this.searchIsIndexingState(userId).update(() => indexing);
}
private async getIsIndexing(): Promise<boolean> {
return await firstValueFrom(this.searchIsIndexing$);
private async getIsIndexing(userId: UserId): Promise<boolean> {
return await firstValueFrom(this.searchIsIndexing$(userId));
}
private fieldExtractor(c: CipherView, joined: boolean) {

View File

@@ -363,7 +363,8 @@ describe("Cipher Service", () => {
configService.getFeatureFlag.mockResolvedValue(true);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
searchService.indexedEntityId$ = of(null);
searchService.indexedEntityId$.mockReturnValue(of(null));
stateService.getUserId.mockResolvedValue(mockUserId);
const keys = {

View File

@@ -165,9 +165,9 @@ export class CipherService implements CipherServiceAbstraction {
}
if (this.searchService != null) {
if (value == null) {
await this.searchService.clearIndex();
await this.searchService.clearIndex(userId);
} else {
await this.searchService.indexCiphers(value);
await this.searchService.indexCiphers(userId, value);
}
}
}
@@ -480,9 +480,9 @@ export class CipherService implements CipherServiceAbstraction {
private async reindexCiphers(userId: UserId) {
const reindexRequired =
this.searchService != null &&
((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId;
((await firstValueFrom(this.searchService.indexedEntityId$(userId))) ?? userId) !== userId;
if (reindexRequired) {
await this.searchService.indexCiphers(await this.getDecryptedCiphers(userId), userId);
await this.searchService.indexCiphers(userId, await this.getDecryptedCiphers(userId), userId);
}
}

View File

@@ -3,8 +3,12 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject, first, Subject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { mockAccountServiceWith } from "../../../../../common/spec";
import { SendItemsService } from "./send-items.service";
import { SendListFiltersService } from "./send-list-filters.service";
@@ -30,6 +34,7 @@ describe("SendItemsService", () => {
{ provide: SendService, useValue: sendServiceMock },
{ provide: SendListFiltersService, useValue: sendListFiltersServiceMock },
{ provide: SearchService, useValue: searchServiceMock },
{ provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) },
SendItemsService,
],
});

View File

@@ -15,6 +15,8 @@ import {
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@@ -71,9 +73,13 @@ export class SendItemsService {
/**
* Observable that indicates whether a filter is currently applied to the sends.
*/
hasFilterApplied$ = combineLatest([this._searchText$, this.sendListFiltersService.filters$]).pipe(
switchMap(([searchText, filters]) => {
return from(this.searchService.isSearchable(searchText)).pipe(
hasFilterApplied$ = combineLatest([
this._searchText$,
this.sendListFiltersService.filters$,
getUserId(this.accountService.activeAccount$),
]).pipe(
switchMap(([searchText, filters, activeAcctId]) => {
return from(this.searchService.isSearchable(activeAcctId, searchText)).pipe(
map(
(isSearchable) =>
isSearchable || Object.values(filters).some((filter) => filter !== null),
@@ -98,6 +104,7 @@ export class SendItemsService {
private sendService: SendService,
private sendListFiltersService: SendListFiltersService,
private searchService: SearchService,
private accountService: AccountService,
) {}
applyFilter(newSearchText: string) {