1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-24 04:04:24 +00:00
Files
browser/libs/admin-console/src/common/collections/services/default-collection.service.ts
Brandon Treston 27089fbb57 [PM-24227] Enable TS-strict for Collection Domain models (#15765)
* wip ts-strict

* wip ts-strict

* wip

* cleanup

* cleanup

* fix story

* fix story

* fix story

* wip

* clean up CollectionAdminView construction

* fix deprecated function call

* fix cli

* clean up

* fix story

* wip

* fix cli

* requested changes

* clean up, fixing minor bugs, more type saftey

* assign props in static ctor, clean up
2025-08-14 13:08:24 -04:00

251 lines
7.7 KiB
TypeScript

import {
combineLatest,
delayWhen,
filter,
firstValueFrom,
from,
map,
NEVER,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
const NestingDelimiter = "/";
export class DefaultCollectionService implements CollectionService {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
protected stateProvider: StateProvider,
) {}
private collectionViewCache = new Map<UserId, Observable<CollectionView[]>>();
/**
* @returns a SingleUserState for encrypted collection data.
*/
private encryptedState(userId: UserId): SingleUserState<Record<CollectionId, CollectionData>> {
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
}
/**
* @returns a SingleUserState for decrypted collection data.
*/
private decryptedState(userId: UserId): SingleUserState<CollectionView[] | null> {
return this.stateProvider.getUser(userId, DECRYPTED_COLLECTION_DATA_KEY);
}
encryptedCollections$(userId: UserId): Observable<Collection[] | null> {
return this.encryptedState(userId).state$.pipe(
map((collections) => {
if (collections == null) {
return null;
}
return Object.values(collections).map((c) => Collection.fromCollectionData(c));
}),
);
}
decryptedCollections$(userId: UserId): Observable<CollectionView[]> {
const cachedResult = this.collectionViewCache.get(userId);
if (cachedResult) {
return cachedResult;
}
const result$ = this.decryptedState(userId).state$.pipe(
switchMap((decryptedState) => {
// If decrypted state is already populated, return that
if (decryptedState !== null) {
return of(decryptedState ?? []);
}
return this.initializeDecryptedState(userId).pipe(switchMap(() => NEVER));
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.collectionViewCache.set(userId, result$);
return result$;
}
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
return combineLatest([
this.encryptedCollections$(userId),
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => !!orgKeys)),
]).pipe(
switchMap(([collections, orgKeys]) =>
this.decryptMany$(collections, orgKeys).pipe(
delayWhen((collections) => this.setDecryptedCollections(collections, userId)),
),
),
);
}
async upsert(toUpdate: CollectionData, userId: UserId): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
collections[toUpdate.id] = toUpdate;
return collections;
});
const decryptedCollections = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
switchMap((orgKeys) => {
if (!orgKeys) {
throw new Error("No key for this collection's organization.");
}
return this.decryptMany$([Collection.fromCollectionData(toUpdate)], orgKeys);
}),
),
);
await this.decryptedState(userId).update((collections) => {
if (collections == null) {
collections = [];
}
if (!decryptedCollections?.length) {
return collections;
}
const decryptedCollection = decryptedCollections[0];
const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id);
if (existingIndex >= 0) {
collections[existingIndex] = decryptedCollection;
} else {
collections.push(decryptedCollection);
}
return collections;
});
}
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => collections);
await this.decryptedState(userId).update(() => null);
}
async delete(ids: CollectionId[], userId: UserId): Promise<any> {
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
ids.forEach((i) => {
delete collections[i];
});
return collections;
});
await this.decryptedState(userId).update((collections) => {
if (collections == null) {
collections = [];
}
ids.forEach((i) => {
if (collections?.length) {
collections = collections.filter((c) => c.id != i) ?? [];
}
});
return collections;
});
}
async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
const key = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filter((orgKeys) => !!orgKeys),
map((k) => k[model.organizationId as OrganizationId]),
),
);
return await model.encrypt(key, this.encryptService);
}
// TODO: this should be private.
// See https://bitwarden.atlassian.net/browse/PM-12375
decryptMany$(
collections: Collection[] | null,
orgKeys: Record<OrganizationId, OrgKey>,
): Observable<CollectionView[]> {
if (collections === null || collections.length == 0 || orgKeys === null) {
return of([]);
}
const decCollections: Observable<CollectionView>[] = [];
collections.forEach((collection) => {
decCollections.push(
from(
collection.decrypt(
orgKeys[collection.organizationId as OrganizationId],
this.encryptService,
),
),
);
});
return combineLatest(decCollections).pipe(
map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
);
}
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => {
const collectionCopy = Object.assign(new CollectionView({ ...c }), c);
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
});
return nodes;
}
/**
* @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated
*/
getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
const nestedCollections = this.getAllNested(collections);
return ServiceUtils.getTreeNodeObjectFromList(
nestedCollections,
id,
) as TreeNode<CollectionView>;
}
/**
* Sets the decrypted collections state for a user.
* @param collections the decrypted collections
* @param userId the user id
*/
private async setDecryptedCollections(
collections: CollectionView[],
userId: UserId,
): Promise<void> {
await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId);
}
}