+
+
{{ trashCleanupWarning }}
-
-
-
-
- {{ "loading" | i18n }}
-
-
-
-
{{ "noItemsInList" | i18n }}
-
-
+
+
+
+ {{ "loading" | i18n }}
+
+
+
+
{{ "noItemsInList" | i18n }}
+
+
+
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 247eb3e763d..9a8972c9123 100644
--- a/apps/web/src/app/vault/individual-vault/vault.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault.component.ts
@@ -67,7 +67,7 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FilterService } from "@bitwarden/common/vault/search/filter.service";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
-import { DialogService, Icons, ToastService } from "@bitwarden/components";
+import { CardComponent, DialogService, Icons, ToastService } from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
@@ -140,7 +140,7 @@ const SearchTextDebounceInterval = 200;
VaultFilterModule,
VaultItemsModule,
SharedModule,
- DecryptionFailureDialogComponent,
+ CardComponent,
],
providers: [
RoutedVaultFilterService,
diff --git a/libs/common/src/vault/search/saved-filters.service.ts b/libs/common/src/vault/search/saved-filters.service.ts
new file mode 100644
index 00000000000..daed8b19f10
--- /dev/null
+++ b/libs/common/src/vault/search/saved-filters.service.ts
@@ -0,0 +1,144 @@
+import { Observable, combineLatestWith, map, mergeMap } from "rxjs";
+import { Tagged } from "type-fest/source/opaque";
+
+// eslint-disable-next-line no-restricted-imports --- TODO move this outside of common
+import { KeyService } from "../../../../key-management/src/abstractions/key.service";
+import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
+import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
+import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
+import {
+ SingleUserStateProvider,
+ UserKeyDefinition,
+ VAULT_SETTINGS_DISK,
+} from "../../platform/state";
+import { OrganizationId, UserId } from "../../types/guid";
+
+export abstract class SavedFiltersService {
+ abstract filtersFor$(userId: UserId): Observable
>;
+ abstract SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise;
+}
+
+export type FilterName = string & Tagged<"FilterName">;
+export type FilterString = string & Tagged<"FilterString">;
+
+type UserSearchFilters = Record;
+type DecryptedSearchFilters = Record;
+
+const SavedFiltersStateDefinition = new UserKeyDefinition(
+ VAULT_SETTINGS_DISK,
+ "SavedFilters",
+ {
+ deserializer: (value) => {
+ if (value == null) {
+ return {};
+ }
+
+ const result: Record = {};
+ for (const [k, v] of Object.entries(value)) {
+ const key = k as EncryptedString;
+ result[key] = EncString.fromJSON(v);
+ }
+ return result;
+ },
+ clearOn: ["logout"],
+ },
+);
+
+export class DefaultSavedFiltersService implements SavedFiltersService {
+ constructor(
+ private readonly stateProvider: SingleUserStateProvider,
+ private readonly encryptService: EncryptService,
+ private readonly keyService: KeyService,
+ ) {}
+
+ filtersFor$(userId: UserId, orgId?: OrganizationId): Observable {
+ const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);
+ const decryptedState = state.state$.pipe(
+ combineLatestWith(this.keyService.userKey$(userId)),
+ mergeMap(async ([state, userKey]) => {
+ if (userKey == null || state == null) {
+ return {};
+ }
+ return await this.decryptFilters(state, userKey);
+ }),
+ );
+
+ return decryptedState;
+ }
+
+ async SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise {
+ const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);
+ await state.update((_, newState) => newState, {
+ combineLatestWith: state.state$.pipe(
+ combineLatestWith(this.keyService.userKey$(userId)),
+ mergeMap(async ([encrypted, userKey]) => {
+ return [await this.decryptFilters(encrypted, userKey), userKey] as const;
+ }),
+ map(([oldState, userKey]) => {
+ oldState ??= {};
+ oldState[name] = filter;
+ return [oldState, userKey] as const;
+ }),
+ mergeMap(async ([newState, userKey]) => {
+ return await this.encryptHistory(newState, userKey);
+ }),
+ ),
+ shouldUpdate: (oldEncrypted, newEncrypted) => !recordsEqual(oldEncrypted, newEncrypted),
+ });
+ }
+
+ private async decryptFilters(
+ history: UserSearchFilters | null,
+ userKey: SymmetricCryptoKey | null,
+ ): Promise {
+ const decrypted: DecryptedSearchFilters = {};
+ if (history == null || userKey == null) {
+ return decrypted;
+ }
+
+ for (const [k, v] of Object.entries(history)) {
+ const encryptedKey = new EncString(k as EncryptedString);
+ const key = (await encryptedKey.decryptWithKey(userKey, this.encryptService)) as FilterName;
+ decrypted[key] = (await v.decryptWithKey(userKey, this.encryptService)) as FilterString;
+ }
+ return decrypted;
+ }
+
+ private async encryptHistory(
+ history: DecryptedSearchFilters | null,
+ userKey: SymmetricCryptoKey | null,
+ ) {
+ if (history == null || userKey == null) {
+ return null;
+ }
+
+ const encrypted: UserSearchFilters = {};
+ for (const [k, v] of Object.entries(history)) {
+ const DecryptedKey = k as FilterName;
+ const key = (await this.encryptService.encrypt(DecryptedKey, userKey)).encryptedString!;
+ encrypted[key] = await this.encryptService.encrypt(v, userKey);
+ }
+ return encrypted;
+ }
+}
+
+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 (a[k].encryptedString !== b[k].encryptedString) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/libs/components/src/card/card.component.ts b/libs/components/src/card/card.component.ts
index fdb02f280da..813fd548459 100644
--- a/libs/components/src/card/card.component.ts
+++ b/libs/components/src/card/card.component.ts
@@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component } from "@angular/core";
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class:
- "tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg [&:not(bit-layout_*)]:tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2",
+ "tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 tw-rounded-lg tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2",
},
})
export class CardComponent {}
diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html
index 33b8de81572..969c0c16abb 100644
--- a/libs/components/src/layout/layout.component.html
+++ b/libs/components/src/layout/layout.component.html
@@ -18,7 +18,7 @@
diff --git a/libs/components/src/search/search.component.html b/libs/components/src/search/search.component.html
index 609bf66e740..1af0e4b5678 100644
--- a/libs/components/src/search/search.component.html
+++ b/libs/components/src/search/search.component.html
@@ -22,6 +22,14 @@
[disabled]="disabled"
[attr.autocomplete]="autocomplete"
/>
+