diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 73973e7ffde..613618c9d1e 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -10,6 +10,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { SearchHistoryService } from "@bitwarden/common/vault/search/search-history.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; @@ -48,6 +49,7 @@ export class VaultFilterComponent protected billingApiService: BillingApiServiceAbstraction, protected dialogService: DialogService, protected configService: ConfigService, + searchHistoryService: SearchHistoryService, ) { super( vaultFilterService, @@ -58,6 +60,7 @@ export class VaultFilterComponent billingApiService, dialogService, configService, + searchHistoryService, ); } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index 65cd26bafee..0b2b938beac 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -51,6 +51,8 @@ [organization]="organization" [activeFilter]="activeFilter" [searchText]="currentSearchText$ | async" + [userId]="userId$ | async" + [organizationId]="organizationId$ | async" (searchTextChanged)="filterSearchText($event)" > diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index ec92597dc7b..2a6d55c119e 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -181,6 +181,8 @@ export class VaultComponent implements OnInit, OnDestroy { protected resellerWarning$: Observable; protected prevCipherId: string | null = null; protected userId: UserId; + protected organizationId$: Observable; + protected userId$: Observable; /** * A list of collections that the user can assign items to and edit those items within. * @protected @@ -262,7 +264,8 @@ export class VaultComponent implements OnInit, OnDestroy { ) {} async ngOnInit() { - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.userId$ = getUserId(this.accountService.activeAccount$); + this.userId = await firstValueFrom(this.userId$); this.resellerManagedOrgAlert = await this.configService.getFeatureFlag( FeatureFlag.ResellerManagedOrgAlert, @@ -275,8 +278,8 @@ export class VaultComponent implements OnInit, OnDestroy { ); const filter$ = this.routedVaultFilterService.filter$; - const organizationId$ = filter$.pipe( - map((filter) => filter.organizationId), + this.organizationId$ = filter$.pipe( + map((filter) => filter.organizationId as OrganizationId), filter((filter) => filter !== undefined), distinctUntilChanged(), ); @@ -284,7 +287,7 @@ export class VaultComponent implements OnInit, OnDestroy { const organization$ = this.accountService.activeAccount$.pipe( map((account) => account?.id), switchMap((id) => - organizationId$.pipe( + this.organizationId$.pipe( switchMap((organizationId) => this.organizationService .organizations$(id) @@ -349,7 +352,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( - switchMap(() => organizationId$), + switchMap(() => this.organizationId$), switchMap((orgId) => this.collectionAdminService.getAll(orgId)), shareReplay({ refCount: false, bufferSize: 1 }), ); @@ -367,7 +370,7 @@ export class VaultComponent implements OnInit, OnDestroy { ); const allCollections$ = combineLatest([ - organizationId$, + this.organizationId$, this.allCollectionsWithoutUnassigned$, ]).pipe( map(([organizationId, allCollections]) => { @@ -379,7 +382,7 @@ export class VaultComponent implements OnInit, OnDestroy { }), ); - const allGroups$ = organizationId$.pipe( + const allGroups$ = this.organizationId$.pipe( switchMap((organizationId) => this.groupService.getAll(organizationId)), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html index 3ac5e708e8c..0e90f5fad42 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html @@ -25,6 +25,7 @@ placeholder="{{ searchPlaceholder | i18n }}" [(ngModel)]="searchText" (ngModelChange)="onSearchTextChanged($event)" + [history]="searchHistory.history$ | async" autocomplete="off" appAutofocus /> diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 786c5de740e..fb1e5030c55 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; +import { debounceTime, firstValueFrom, merge, mergeMap, Subject, switchMap, takeUntil } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -10,8 +10,14 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { + SearchHistory, + SearchHistoryService, +} from "@bitwarden/common/vault/search/search-history.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; @@ -43,6 +49,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { @Input() searchText = ""; @Output() searchTextChanged = new EventEmitter(); + @Input({ required: true }) userId!: UserId; + @Input() organizationId: OrganizationId | undefined; isLoaded = false; @@ -91,6 +99,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } private trialFlowService = inject(TrialFlowService); + protected searchHistory: SearchHistory; constructor( protected vaultFilterService: VaultFilterService, @@ -101,6 +110,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected billingApiService: BillingApiServiceAbstraction, protected dialogService: DialogService, protected configService: ConfigService, + protected searchHistoryService: SearchHistoryService, ) {} async ngOnInit(): Promise { @@ -121,6 +131,21 @@ export class VaultFilterComponent implements OnInit, OnDestroy { .subscribe((orgFilters) => { this.filters.organizationFilter = orgFilters; }); + + this.searchHistory = this.searchHistoryService.searchHistory(this.userId, this.organizationId); + this.searchTextChanged + .asObservable() + .pipe( + debounceTime(1000), + mergeMap(async (searchText) => { + if (Utils.isNullOrEmpty(searchText)) { + return; + } + await this.searchHistory.push(searchText); + }), + takeUntil(this.destroy$), + ) + .subscribe(); } ngOnDestroy() { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 1a2a1fdbca6..cd011bd052b 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -28,6 +28,7 @@ #vaultFilter [activeFilter]="activeFilter" [searchText]="currentSearchText$ | async" + [userId]="userId$ | async" (searchTextChanged)="filterSearchText($event)" (onEditFolder)="editFolder($event)" > 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 6c88ea7c840..247eb3e763d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -174,11 +174,12 @@ export class VaultComponent implements OnInit, OnDestroy { private refresh$ = new BehaviorSubject(null); private destroy$ = new Subject(); private hasSubscription$ = new BehaviorSubject(false); + protected userId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); private vaultItemDialogRef?: DialogRef | undefined; - private organizations$ = this.accountService.activeAccount$ - .pipe(map((a) => a?.id)) - .pipe(switchMap((id) => this.organizationService.organizations$(id))); + private organizations$ = this.userId$.pipe( + switchMap((id) => this.organizationService.organizations$(id)), + ); private readonly unpaidSubscriptionDialog$ = this.organizations$.pipe( filter((organizations) => organizations.length === 1), diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 488d6690cce..5810ed5a7ed 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -270,6 +270,10 @@ import { import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { DefaultFilterService, FilterService } from "@bitwarden/common/vault/search/filter.service"; +import { + SearchHistoryService, + DefaultSearchHistoryService, +} from "@bitwarden/common/vault/search/search-history.service"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, @@ -1471,6 +1475,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultFilterService, deps: [], }), + safeProvider({ + provide: SearchHistoryService, + useClass: DefaultSearchHistoryService, + deps: [SingleUserStateProvider, EncryptService, KeyService], + }), ]; @NgModule({ diff --git a/libs/common/src/vault/search/search-history.service.ts b/libs/common/src/vault/search/search-history.service.ts new file mode 100644 index 00000000000..5060e403d83 --- /dev/null +++ b/libs/common/src/vault/search/search-history.service.ts @@ -0,0 +1,222 @@ +import { Observable, combineLatestWith, map, mergeMap } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports -- TODO this will need to move +import { KeyService } from "../../../../key-management/src/abstractions/key.service"; +import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { + SingleUserState, + SingleUserStateProvider, + UserKeyDefinition, + VAULT_SETTINGS_DISK, +} from "../../platform/state"; +import { OrganizationId, UserId } from "../../types/guid"; + +export abstract class SearchHistoryService { + abstract searchHistory(userId: UserId, orgId?: OrganizationId | undefined): SearchHistory; +} + +export class SearchHistory { + history$: Observable; + private readonly userHistoryIndexer: UserId | OrganizationId; + constructor( + readonly userId: UserId, + readonly orgId: OrganizationId | undefined, + readonly historyLength: number, + private readonly userHistory$: Observable, + private readonly updateCallback: (newSearch: string) => Promise, + ) { + this.userHistoryIndexer = orgId ?? userId; + this.history$ = userHistory$.pipe(map((h) => h?.[this.userHistoryIndexer] ?? [])); + } + + async push(newSearch: string) { + await this.updateCallback(newSearch); + } +} + +type UserSearchHistory = Record; +type DecryptedSearchHistory = Record; + +const SearchHistoryStateDefinition = new UserKeyDefinition( + VAULT_SETTINGS_DISK, + "searchHistory", + { + deserializer: (value) => { + if (value == null) { + return {}; + } + + const result: Record = {}; + for (const [k, v] of Object.entries(value)) { + const key = k as UserId | OrganizationId; + const value = v as string[]; + result[key] = value?.map((v) => new EncString(v)) ?? []; + } + return result; + }, + clearOn: ["logout"], + }, +); + +export class DefaultSearchHistoryService implements SearchHistoryService { + private readonly historyLength = 3; + + constructor( + private readonly stateProvider: SingleUserStateProvider, + private readonly encryptService: EncryptService, + private readonly keyService: KeyService, + ) {} + + searchHistory(userId: UserId, orgId?: OrganizationId): SearchHistory { + const state = this.stateProvider.get(userId, SearchHistoryStateDefinition); + const decryptedState = state.state$.pipe( + combineLatestWith(this.keyService.userKey$(userId)), + mergeMap(async ([state, userKey]) => { + if (userKey == null || state == null) { + return {}; + } + return await this.decryptHistory(state, userKey); + }), + ); + + return new SearchHistory(userId, orgId, this.historyLength, decryptedState, (newSearch) => + this.updateHistory(userId, orgId, newSearch, state), + ); + } + + private async decryptHistory( + history: UserSearchHistory | null, + userKey: SymmetricCryptoKey | null, + ): Promise { + const decrypted: DecryptedSearchHistory = {}; + if (history == null || userKey == null) { + return decrypted; + } + + for (const [k, v] of Object.entries(history)) { + const key = k as UserId | OrganizationId; + decrypted[key] = []; + for (const item of v) { + decrypted[key].push(await this.encryptService.decryptToUtf8(item, userKey)); + } + } + return decrypted; + } + + private async encryptHistory( + history: DecryptedSearchHistory | null, + userKey: SymmetricCryptoKey | null, + ) { + if (history == null || userKey == null) { + return null; + } + + const encrypted: UserSearchHistory = {}; + for (const [k, v] of Object.entries(history)) { + const key = k as UserId | OrganizationId; + encrypted[key] = []; + for (const item of v) { + encrypted[key].push(await this.encryptService.encrypt(item, userKey)); + } + } + return encrypted; + } + + private async updateHistory( + userId: UserId, + orgId: OrganizationId | undefined, + newSearch: string, + state: SingleUserState, + ): Promise { + const userHistoryIndexer = orgId ?? userId; + await state.update((_, newState) => newState, { + combineLatestWith: state.state$.pipe( + combineLatestWith(this.keyService.userKey$(userId)), + mergeMap(async ([encrypted, userKey]) => { + return [await this.decryptHistory(encrypted, userKey), userKey] as const; + }), + map(([userHistory, userKey]) => { + userHistory ??= {}; + const history = userHistory[userHistoryIndexer] ?? []; + // Use combineLatestWith to update to the latest state + if (newSearch == null || newSearch === "") { + userHistory[userHistoryIndexer] = history; + return [userHistory, userKey] as const; + } + + if (history == null) { + userHistory[userHistoryIndexer] = [newSearch]; + return [userHistory, userKey] as const; + } + + const existing = history.findIndex((prev) => prev === newSearch); + const newHistory = [...history]; + if (existing > -1) { + newHistory.splice(existing, 1); + newHistory.splice(0, 0, newSearch); + } else { + newHistory.splice(0, 0, newSearch); + if (newHistory.length > this.historyLength) { + newHistory.splice(this.historyLength, newHistory.length - this.historyLength); + } + } + userHistory[userHistoryIndexer] = newHistory; + return [userHistory, userKey] as const; + }), + mergeMap(async ([decrypted, userKey]) => { + return await this.encryptHistory(decrypted, userKey); + }), + ), + shouldUpdate: (oldHistory, newHistory) => !recordsEqual(oldHistory, newHistory), + }); + } +} + +function stringArraysEqual( + a: (string | undefined)[] | null, + b: (string | undefined)[] | null, +): boolean { + if (a == null && b == null) { + return true; + } + if (a == null || b == null) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +function recordsEqual( + a: Record | null, + b: Record | null, +): boolean { + if (a == null && b == null) { + return true; + } + if (a == null || b == null) { + return false; + } + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (const k of Object.keys(a)) { + if ( + !stringArraysEqual( + a[k].map((e) => e.encryptedString), + b[k].map((e) => e.encryptedString), + ) + ) { + return false; + } + } + return true; +} diff --git a/libs/components/src/search/search.component.html b/libs/components/src/search/search.component.html index 5bb25425e57..16ecf645680 100644 --- a/libs/components/src/search/search.component.html +++ b/libs/components/src/search/search.component.html @@ -1,23 +1,43 @@ -
- - +
+
+ + +
+ +
+
    +
  • + {{ search }} +
  • +
+
+
diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts index 7f1bd781e9d..e28074f1aa6 100644 --- a/libs/components/src/search/search.component.ts +++ b/libs/components/src/search/search.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CommonModule } from "@angular/common"; import { Component, ElementRef, Input, ViewChild } from "@angular/core"; import { ControlValueAccessor, @@ -7,6 +8,7 @@ import { ReactiveFormsModule, FormsModule, } from "@angular/forms"; +import { BehaviorSubject, map } from "rxjs"; import { isBrowserSafariApi } from "@bitwarden/platform"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -31,7 +33,7 @@ let nextId = 0; }, ], standalone: true, - imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe], + imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe, CommonModule], }) export class SearchComponent implements ControlValueAccessor, FocusableElement { private notifyOnChange: (v: string) => void; @@ -43,10 +45,26 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { protected searchText: string; // Use `type="text"` for Safari to improve rendering performance protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const); + private focused = false; + private textUpdated$ = new BehaviorSubject(""); @Input() disabled: boolean; @Input() placeholder: string; @Input() autocomplete: string; + @Input() history: string[] | null; + + get showHistory() { + return this.history != null && this.focused; + } + get filteredHistory$() { + // TODO: Not clear if filtering is better or worse + return this.textUpdated$.pipe(map((text) => this.history)); + // return this.textUpdated$.pipe(map((text) => this.history.filter((h) => h.startsWith(text)))); + } + + onFocus() { + this.focused = true; + } getFocusTarget() { return this.input.nativeElement; @@ -56,9 +74,12 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { if (this.notifyOnChange != undefined) { this.notifyOnChange(searchText); } + this.textUpdated$.next(searchText); } onTouch() { + // Need to provide enough time for a history option to be selected, it if it's clicked + setTimeout(() => (this.focused = false), 100); if (this.notifyOnTouch != undefined) { this.notifyOnTouch(); } diff --git a/libs/components/src/search/search.stories.ts b/libs/components/src/search/search.stories.ts index a6cd714d43a..641ca133910 100644 --- a/libs/components/src/search/search.stories.ts +++ b/libs/components/src/search/search.stories.ts @@ -40,3 +40,13 @@ export const Default: Story = { }), args: {}, }; + +export const WithHistory: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + args: {}, +};