1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[PM-12048] Wire up vNextCollectionService (#14871)

* remove derived state, add cache in service. Fix ts strict errors

* cleanup

* promote vNextCollectionService

* wip

* replace callers in web WIP

* refactor tests for web

* update callers to use vNextCollectionServcie methods in CLI

* WIP make decryptMany public again, fix callers, imports

* wip cli

* wip desktop

* update callers in browser, fix tests

* remove in service cache

* cleanup

* fix test

* clean up

* address cr feedback

* remove duplicate userId

* clean up

* remove unused import

* fix vault-settings-import-nudge.service

* fix caching issue

* clean up

* refactor decryption, cleanup, update callers

* clean up

* Use in-memory statedefinition

* Ac/pm 12048 v next collection service pairing (#15239)

* Draft from pairing with Gibson

* Add todos

* Add comment

* wip

* refactor upsert

---------

Co-authored-by: Brandon <btreston@bitwarden.com>

* clean up

* fix state definitions

* fix linter error

* cleanup

* add test, fix shareReplay

* fix item-more-options component

* fix desktop build

* refactor state to account for null as an initial value, remove caching

* add proper cache, add unit test, update callers

* clean up

* fix routing when deleting collections

* cleanup

* use combineLatest

* fix ts-strict errors, fix error handling

* refactor Collection and CollectionView properties for ts-strict

* Revert "refactor Collection and CollectionView properties for ts-strict"

This reverts commit a5c63aab76.

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Brandon Treston
2025-07-23 19:05:15 -04:00
committed by GitHub
parent 7a24a538a4
commit d0d1359ff4
56 changed files with 906 additions and 1112 deletions

View File

@@ -1030,18 +1030,23 @@ export default class NotificationBackground {
private async getCollectionData( private async getCollectionData(
message: NotificationBackgroundExtensionMessage, message: NotificationBackgroundExtensionMessage,
): Promise<CollectionView[]> { ): Promise<CollectionView[]> {
const collections = (await this.collectionService.getAllDecrypted()).reduce<CollectionView[]>( const collections = await firstValueFrom(
(acc, collection) => { this.accountService.activeAccount$.pipe(
if (collection.organizationId === message?.orgId) { getUserId,
acc.push({ switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
id: collection.id, map((collections) =>
name: collection.name, collections.reduce<CollectionView[]>((acc, collection) => {
organizationId: collection.organizationId, if (collection.organizationId === message?.orgId) {
}); acc.push({
} id: collection.id,
return acc; name: collection.name,
}, organizationId: collection.organizationId,
[], });
}
return acc;
}, []),
),
),
); );
return collections; return collections;
} }

View File

@@ -1620,7 +1620,6 @@ export default class MainBackground {
this.keyService.clearKeys(userBeingLoggedOut), this.keyService.clearKeys(userBeingLoggedOut),
this.cipherService.clear(userBeingLoggedOut), this.cipherService.clear(userBeingLoggedOut),
this.folderService.clear(userBeingLoggedOut), this.folderService.clear(userBeingLoggedOut),
this.collectionService.clear(userBeingLoggedOut),
this.vaultTimeoutSettingsService.clear(userBeingLoggedOut), this.vaultTimeoutSettingsService.clear(userBeingLoggedOut),
this.vaultFilterService.clear(), this.vaultFilterService.clear(),
this.biometricStateService.logout(userBeingLoggedOut), this.biometricStateService.logout(userBeingLoggedOut),

View File

@@ -10,6 +10,7 @@ import { Observable, combineLatest, filter, first, map, switchMap } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -70,7 +71,12 @@ export class AssignCollections {
), ),
); );
combineLatest([cipher$, this.collectionService.decryptedCollections$]) const decryptedCollection$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
);
combineLatest([cipher$, decryptedCollection$])
.pipe(takeUntilDestroyed(), first()) .pipe(takeUntilDestroyed(), first())
.subscribe(([cipherView, collections]) => { .subscribe(([cipherView, collections]) => {
let availableCollections = collections; let availableCollections = collections;

View File

@@ -93,7 +93,7 @@ export class ItemMoreOptionsComponent {
switchMap((userId) => { switchMap((userId) => {
return combineLatest([ return combineLatest([
this.organizationService.hasOrganizations(userId), this.organizationService.hasOrganizations(userId),
this.collectionService.decryptedCollections$, this.collectionService.decryptedCollections$(userId),
]).pipe( ]).pipe(
map(([hasOrgs, collections]) => { map(([hasOrgs, collections]) => {
const canEditCollections = collections.some((c) => !c.readOnly); const canEditCollections = collections.some((c) => !c.readOnly);

View File

@@ -139,7 +139,7 @@ describe("VaultPopupItemsService", () => {
]; ];
organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg])); organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg]));
collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections); collectionService.decryptedCollections$.mockReturnValue(new BehaviorSubject(mockCollections));
activeUserLastSync$ = new BehaviorSubject(new Date()); activeUserLastSync$ = new BehaviorSubject(new Date());
syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$); syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$);

View File

@@ -72,6 +72,11 @@ export class VaultPopupItemsService {
private organizations$ = this.activeUserId$.pipe( private organizations$ = this.activeUserId$.pipe(
switchMap((userId) => this.organizationService.organizations$(userId)), switchMap((userId) => this.organizationService.organizations$(userId)),
); );
private decryptedCollections$ = this.activeUserId$.pipe(
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
);
/** /**
* Observable that contains the list of other cipher types that should be shown * Observable that contains the list of other cipher types that should be shown
* in the autofill section of the Vault tab. Depends on vault settings. * in the autofill section of the Vault tab. Depends on vault settings.
@@ -130,7 +135,7 @@ export class VaultPopupItemsService {
private _activeCipherList$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe( private _activeCipherList$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) => switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( combineLatest([this.organizations$, this.decryptedCollections$]).pipe(
map(([organizations, collections]) => { map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
@@ -291,7 +296,7 @@ export class VaultPopupItemsService {
*/ */
deletedCiphers$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe( deletedCiphers$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) => switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( combineLatest([this.organizations$, this.decryptedCollections$]).pipe(
map(([organizations, collections]) => { map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));

View File

@@ -20,6 +20,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { import {
@@ -58,7 +59,7 @@ describe("VaultPopupListFiltersService", () => {
}; };
const collectionService = { const collectionService = {
decryptedCollections$, decryptedCollections$: () => decryptedCollections$,
getAllNested: () => Promise.resolve([]), getAllNested: () => Promise.resolve([]),
} as unknown as CollectionService; } as unknown as CollectionService;
@@ -106,7 +107,7 @@ describe("VaultPopupListFiltersService", () => {
signal: jest.fn(() => mockCachedSignal), signal: jest.fn(() => mockCachedSignal),
}; };
collectionService.getAllNested = () => Promise.resolve([]); collectionService.getAllNested = () => [];
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{ {
@@ -382,14 +383,7 @@ describe("VaultPopupListFiltersService", () => {
beforeEach(() => { beforeEach(() => {
decryptedCollections$.next(testCollections); decryptedCollections$.next(testCollections);
collectionService.getAllNested = () => collectionService.getAllNested = () => testCollections.map((c) => new TreeNode(c, null));
Promise.resolve(
testCollections.map((c) => ({
children: [],
node: c,
parent: null,
})),
);
}); });
it("returns all collections", (done) => { it("returns all collections", (done) => {
@@ -755,15 +749,13 @@ function createSeededVaultPopupListFiltersService(
} as any; } as any;
const collectionServiceMock = { const collectionServiceMock = {
decryptedCollections$: seededCollections$, decryptedCollections$: () => seededCollections$,
getAllNested: () => getAllNested: () =>
Promise.resolve( seededCollections$.value.map((c) => ({
seededCollections$.value.map((c) => ({ children: [],
children: [], node: c,
node: c, parent: null,
parent: null, })),
})),
),
} as any; } as any;
const folderServiceMock = { const folderServiceMock = {

View File

@@ -6,7 +6,6 @@ import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
from,
map, map,
Observable, Observable,
shareReplay, shareReplay,
@@ -446,7 +445,7 @@ export class VaultPopupListFiltersService {
this.filters$.pipe( this.filters$.pipe(
distinctUntilChanged((prev, curr) => prev.organization?.id === curr.organization?.id), distinctUntilChanged((prev, curr) => prev.organization?.id === curr.organization?.id),
), ),
this.collectionService.decryptedCollections$, this.collectionService.decryptedCollections$(userId),
this.organizationService.memberOrganizations$(userId), this.organizationService.memberOrganizations$(userId),
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
]), ]),
@@ -463,16 +462,11 @@ export class VaultPopupListFiltersService {
} }
return sortDefaultCollections(filtered, orgs, this.i18nService.collator); return sortDefaultCollections(filtered, orgs, this.i18nService.collator);
}), }),
switchMap((collections) => { map((fullList) => {
return from(this.collectionService.getAllNested(collections)).pipe( return new DynamicTreeNode<CollectionView>({
map( fullList,
(nested) => nestedList: this.collectionService.getAllNested(fullList),
new DynamicTreeNode<CollectionView>({ });
fullList: collections,
nestedList: nested,
}),
),
);
}), }),
map((tree) => map((tree) =>
tree.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")), tree.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")),

View File

@@ -24,6 +24,7 @@ import { LoginUriExport } from "@bitwarden/common/models/export/login-uri.export
import { LoginExport } from "@bitwarden/common/models/export/login.export"; import { LoginExport } from "@bitwarden/common/models/export/login.export";
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export"; import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -442,8 +443,11 @@ export class GetCommand extends DownloadCommand {
private async getCollection(id: string) { private async getCollection(id: string) {
let decCollection: CollectionView = null; let decCollection: CollectionView = null;
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (Utils.isGuid(id)) { if (Utils.isGuid(id)) {
const collection = await this.collectionService.get(id); const collection = await firstValueFrom(
this.collectionService.encryptedCollections$(activeUserId).pipe(getById(id)),
);
if (collection != null) { if (collection != null) {
const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$); const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$);
decCollection = await collection.decrypt( decCollection = await collection.decrypt(
@@ -451,7 +455,9 @@ export class GetCommand extends DownloadCommand {
); );
} }
} else if (id.trim() !== "") { } else if (id.trim() !== "") {
let collections = await this.collectionService.getAllDecrypted(); let collections = await firstValueFrom(
this.collectionService.decryptedCollections$(activeUserId),
);
collections = CliUtils.searchCollections(collections, id); collections = CliUtils.searchCollections(collections, id);
if (collections.length > 1) { if (collections.length > 1) {
return Response.multipleResults(collections.map((c) => c.id)); return Response.multipleResults(collections.map((c) => c.id));

View File

@@ -20,6 +20,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { KeyService } from "@bitwarden/key-management";
import { CollectionResponse } from "../admin-console/models/response/collection.response"; import { CollectionResponse } from "../admin-console/models/response/collection.response";
import { OrganizationUserResponse } from "../admin-console/models/response/organization-user.response"; import { OrganizationUserResponse } from "../admin-console/models/response/organization-user.response";
@@ -42,6 +43,7 @@ export class ListCommand {
private apiService: ApiService, private apiService: ApiService,
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
private accountService: AccountService, private accountService: AccountService,
private keyService: KeyService,
private cliRestrictedItemTypesService: CliRestrictedItemTypesService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService,
) {} ) {}
@@ -158,7 +160,10 @@ export class ListCommand {
} }
private async listCollections(options: Options) { private async listCollections(options: Options) {
let collections = await this.collectionService.getAllDecrypted(); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let collections = await firstValueFrom(
this.collectionService.decryptedCollections$(activeUserId),
);
if (options.organizationId != null) { if (options.organizationId != null) {
collections = collections.filter((c) => { collections = collections.filter((c) => {
@@ -178,13 +183,13 @@ export class ListCommand {
} }
private async listOrganizationCollections(options: Options) { private async listOrganizationCollections(options: Options) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (options.organizationId == null || options.organizationId === "") { if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required."); return Response.badRequest("`organizationid` option is required.");
} }
if (!Utils.isGuid(options.organizationId)) { if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID."); return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
} }
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (!userId) { if (!userId) {
return Response.badRequest("No user found."); return Response.badRequest("No user found.");
} }
@@ -207,7 +212,13 @@ export class ListCommand {
const collections = response.data const collections = response.data
.filter((c) => c.organizationId === options.organizationId) .filter((c) => c.organizationId === options.organizationId)
.map((r) => new Collection(new CollectionData(r as ApiCollectionDetailsResponse))); .map((r) => new Collection(new CollectionData(r as ApiCollectionDetailsResponse)));
let decCollections = await this.collectionService.decryptMany(collections); const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId));
if (orgKeys == null) {
throw new Error("Organization keys not found.");
}
let decCollections = await firstValueFrom(
this.collectionService.decryptMany$(collections, orgKeys),
);
if (options.search != null && options.search.trim() !== "") { if (options.search != null && options.search.trim() !== "") {
decCollections = CliUtils.searchCollections(decCollections, options.search); decCollections = CliUtils.searchCollections(decCollections, options.search);
} }

View File

@@ -79,6 +79,7 @@ export class OssServeConfigurator {
this.serviceContainer.apiService, this.serviceContainer.apiService,
this.serviceContainer.eventCollectionService, this.serviceContainer.eventCollectionService,
this.serviceContainer.accountService, this.serviceContainer.accountService,
this.serviceContainer.keyService,
this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.cliRestrictedItemTypesService,
); );
this.createCommand = new CreateCommand( this.createCommand = new CreateCommand(

View File

@@ -901,7 +901,6 @@ export class ServiceContainer {
this.keyService.clearKeys(userId), this.keyService.clearKeys(userId),
this.cipherService.clear(userId), this.cipherService.clear(userId),
this.folderService.clear(userId), this.folderService.clear(userId),
this.collectionService.clear(userId),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId); await this.stateEventRunnerService.handleEvent("logout", userId as UserId);

View File

@@ -114,6 +114,7 @@ export class VaultProgram extends BaseProgram {
this.serviceContainer.apiService, this.serviceContainer.apiService,
this.serviceContainer.eventCollectionService, this.serviceContainer.eventCollectionService,
this.serviceContainer.accountService, this.serviceContainer.accountService,
this.serviceContainer.keyService,
this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.cliRestrictedItemTypesService,
); );
const response = await command.run(object, cmd); const response = await command.run(object, cmd);

View File

@@ -677,7 +677,6 @@ export class AppComponent implements OnInit, OnDestroy {
await this.keyService.clearKeys(userBeingLoggedOut); await this.keyService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut);
await this.folderService.clear(userBeingLoggedOut); await this.folderService.clear(userBeingLoggedOut);
await this.collectionService.clear(userBeingLoggedOut);
await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut);
await this.biometricStateService.logout(userBeingLoggedOut); await this.biometricStateService.logout(userBeingLoggedOut);

View File

@@ -30,8 +30,9 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getByIds } from "@bitwarden/common/platform/misc";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
@@ -360,7 +361,12 @@ export class VaultV2Component<C extends CipherViewLike>
this.allOrganizations = orgs; this.allOrganizations = orgs;
}); });
this.collectionService.decryptedCollections$ if (!this.activeUserId) {
throw new Error("No user found.");
}
this.collectionService
.decryptedCollections$(this.activeUserId)
.pipe(takeUntil(this.componentIsDestroyed$)) .pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((collections) => { .subscribe((collections) => {
this.allCollections = collections; this.allCollections = collections;
@@ -701,9 +707,17 @@ export class VaultV2Component<C extends CipherViewLike>
this.cipherId = null; this.cipherId = null;
this.action = "view"; this.action = "view";
await this.vaultItemsComponent?.refresh().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {});
if (!this.activeUserId) {
throw new Error("No userId provided.");
}
this.collections = await firstValueFrom( this.collections = await firstValueFrom(
this.collectionService.decryptedCollectionViews$(cipher.collectionIds as CollectionId[]), this.collectionService
.decryptedCollections$(this.activeUserId)
.pipe(getByIds(cipher.collectionIds)),
); );
this.cipherId = cipher.id; this.cipherId = cipher.id;
this.cipher = cipher; this.cipher = cipher;
if (this.activeUserId) { if (this.activeUserId) {

View File

@@ -29,6 +29,7 @@ import {
import { import {
CollectionAdminService, CollectionAdminService,
CollectionAdminView, CollectionAdminView,
CollectionService,
CollectionView, CollectionView,
Unassigned, Unassigned,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
@@ -264,6 +265,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private accountService: AccountService, private accountService: AccountService,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private organizationWarningsService: OrganizationWarningsService, private organizationWarningsService: OrganizationWarningsService,
private collectionService: CollectionService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -1133,6 +1135,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
try { try {
await this.apiService.deleteCollection(this.organization?.id, collection.id); await this.apiService.deleteCollection(this.organization?.id, collection.id);
await this.collectionService.delete([collection.id as CollectionId], this.userId);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: null,

View File

@@ -11,6 +11,7 @@ import {
from, from,
lastValueFrom, lastValueFrom,
map, map,
Observable,
switchMap, switchMap,
tap, tap,
} from "rxjs"; } from "rxjs";
@@ -25,10 +26,13 @@ import {
CollectionView, CollectionView,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components"; import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { GroupDetailsView, InternalGroupApiService as GroupService } from "../core"; import { GroupDetailsView, InternalGroupApiService as GroupService } from "../core";
@@ -100,6 +104,8 @@ export class GroupsComponent {
private logService: LogService, private logService: LogService,
private collectionService: CollectionService, private collectionService: CollectionService,
private toastService: ToastService, private toastService: ToastService,
private keyService: KeyService,
private accountService: AccountService,
) { ) {
this.route.params this.route.params
.pipe( .pipe(
@@ -244,16 +250,22 @@ export class GroupsComponent {
this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow); this.dataSource.data = this.dataSource.data.filter((g) => g !== groupRow);
} }
private async toCollectionMap(response: ListResponse<CollectionResponse>) { private toCollectionMap(
response: ListResponse<CollectionResponse>,
): Observable<Record<string, CollectionView>> {
const collections = response.data.map( const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)), (r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
); );
const decryptedCollections = await this.collectionService.decryptMany(collections);
// Convert to an object using collection Ids as keys for faster name lookups return this.accountService.activeAccount$.pipe(
const collectionMap: Record<string, CollectionView> = {}; getUserId,
decryptedCollections.forEach((c) => (collectionMap[c.id] = c)); switchMap((userId) => this.keyService.orgKeys$(userId)),
switchMap((orgKeys) => this.collectionService.decryptMany$(collections, orgKeys)),
return collectionMap; map((collections) => {
const collectionMap: Record<string, CollectionView> = {};
collections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
}),
);
} }
} }

View File

@@ -13,6 +13,7 @@ import {
Observable, Observable,
shareReplay, shareReplay,
switchMap, switchMap,
withLatestFrom,
tap, tap,
} from "rxjs"; } from "rxjs";
@@ -307,17 +308,27 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
* Retrieve a map of all collection IDs <-> names for the organization. * Retrieve a map of all collection IDs <-> names for the organization.
*/ */
async getCollectionNameMap() { async getCollectionNameMap() {
const collectionMap = new Map<string, string>(); const response = from(this.apiService.getCollections(this.organization.id)).pipe(
const response = await this.apiService.getCollections(this.organization.id); map((res) =>
res.data.map((r) => new Collection(new CollectionData(r as CollectionDetailsResponse))),
const collections = response.data.map( ),
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
); );
const decryptedCollections = await this.collectionService.decryptMany(collections);
decryptedCollections.forEach((c) => collectionMap.set(c.id, c.name)); const decryptedCollections$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
withLatestFrom(response),
switchMap(([orgKeys, collections]) =>
this.collectionService.decryptMany$(collections, orgKeys),
),
map((collections) => {
const collectionMap = new Map<string, string>();
collections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}),
);
return collectionMap; return await firstValueFrom(decryptedCollections$);
} }
removeUser(id: string): Promise<void> { removeUser(id: string): Promise<void> {

View File

@@ -26,7 +26,6 @@ import {
CollectionResponse, CollectionResponse,
CollectionView, CollectionView,
CollectionService, CollectionService,
Collection,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
import { import {
getOrganizationById, getOrganizationById,
@@ -38,6 +37,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { import {
DIALOG_DATA, DIALOG_DATA,
@@ -141,7 +141,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode; protected PermissionMode = PermissionMode;
protected showDeleteButton = false; protected showDeleteButton = false;
protected showAddAccessWarning = false; protected showAddAccessWarning = false;
protected collections: Collection[];
protected buttonDisplayName: ButtonType = ButtonType.Save; protected buttonDisplayName: ButtonType = ButtonType.Save;
private orgExceedingCollectionLimit!: Organization; private orgExceedingCollectionLimit!: Organization;
@@ -166,14 +165,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
// Opened from the individual vault // Opened from the individual vault
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
if (this.params.showOrgSelector) { if (this.params.showOrgSelector) {
this.showOrgSelector = true; this.showOrgSelector = true;
this.formGroup.controls.selectedOrg.valueChanges this.formGroup.controls.selectedOrg.valueChanges
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((id) => this.loadOrg(id)); .subscribe((id) => this.loadOrg(id));
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
this.organizations$ = this.organizationService.organizations$(userId).pipe( this.organizations$ = this.organizationService.organizations$(userId).pipe(
first(), first(),
map((orgs) => map((orgs) =>
@@ -195,9 +192,14 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
); );
if (isBreadcrumbEventLogsEnabled) { if (isBreadcrumbEventLogsEnabled) {
this.collections = await this.collectionService.getAll();
this.organizationSelected.setAsyncValidators( this.organizationSelected.setAsyncValidators(
freeOrgCollectionLimitValidator(this.organizations$, this.collections, this.i18nService), freeOrgCollectionLimitValidator(
this.organizations$,
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
this.i18nService,
),
); );
this.formGroup.updateValueAndValidity(); this.formGroup.updateValueAndValidity();
} }
@@ -212,7 +214,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
} }
}), }),
filter(() => this.organizationSelected.errors?.cannotCreateCollections), filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)), switchMap((organizationId) => this.organizations$.pipe(getById(organizationId))),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
.subscribe((org) => { .subscribe((org) => {
@@ -222,11 +224,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}); });
} }
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) { async loadOrg(orgId: string) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const organization$ = this.organizationService const organization$ = this.organizationService
@@ -413,7 +410,8 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
collectionView.name = this.formGroup.controls.name.value; collectionView.name = this.formGroup.controls.name.value;
} }
const savedCollection = await this.collectionAdminService.save(collectionView); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const savedCollection = await this.collectionAdminService.save(collectionView, userId);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",

View File

@@ -18,7 +18,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization is not found", async () => { it("returns null if organization is not found", async () => {
const orgs: Organization[] = []; const orgs: Organization[] = [];
const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService); const validator = freeOrgCollectionLimitValidator(of(orgs), of([]), i18nService);
const control = new FormControl("org-id"); const control = new FormControl("org-id");
const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>; const result: Observable<ValidationErrors> = validator(control) as Observable<ValidationErrors>;
@@ -28,7 +28,7 @@ describe("freeOrgCollectionLimitValidator", () => {
}); });
it("returns null if control is not an instance of FormControl", async () => { it("returns null if control is not an instance of FormControl", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService); const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const control = {} as AbstractControl; const control = {} as AbstractControl;
const result: Observable<ValidationErrors | null> = validator( const result: Observable<ValidationErrors | null> = validator(
@@ -40,7 +40,7 @@ describe("freeOrgCollectionLimitValidator", () => {
}); });
it("returns null if control is not provided", async () => { it("returns null if control is not provided", async () => {
const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService); const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService);
const result: Observable<ValidationErrors | null> = validator( const result: Observable<ValidationErrors | null> = validator(
undefined as any, undefined as any,
@@ -53,7 +53,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns null if organization has not reached collection limit (Observable)", async () => { it("returns null if organization has not reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 2 } as Organization; const org = { id: "org-id", maxCollections: 2 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection]; const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService); const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id"); const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>; const result$ = validator(control) as Observable<ValidationErrors | null>;
@@ -65,7 +65,7 @@ describe("freeOrgCollectionLimitValidator", () => {
it("returns error if organization has reached collection limit (Observable)", async () => { it("returns error if organization has reached collection limit (Observable)", async () => {
const org = { id: "org-id", maxCollections: 1 } as Organization; const org = { id: "org-id", maxCollections: 1 } as Organization;
const collections = [{ organizationId: "org-id" } as Collection]; const collections = [{ organizationId: "org-id" } as Collection];
const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService); const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService);
const control = new FormControl("org-id"); const control = new FormControl("org-id");
const result$ = validator(control) as Observable<ValidationErrors | null>; const result$ = validator(control) as Observable<ValidationErrors | null>;

View File

@@ -1,13 +1,14 @@
import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms"; import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms";
import { map, Observable, of } from "rxjs"; import { combineLatest, map, Observable, of } from "rxjs";
import { Collection } from "@bitwarden/admin-console/common"; import { Collection } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
export function freeOrgCollectionLimitValidator( export function freeOrgCollectionLimitValidator(
orgs: Observable<Organization[]>, organizations$: Observable<Organization[]>,
collections: Collection[], collections$: Observable<Collection[]>,
i18nService: I18nService, i18nService: I18nService,
): AsyncValidatorFn { ): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => { return (control: AbstractControl): Observable<ValidationErrors | null> => {
@@ -21,15 +22,16 @@ export function freeOrgCollectionLimitValidator(
return of(null); return of(null);
} }
return orgs.pipe( return combineLatest([organizations$.pipe(getById(orgId)), collections$]).pipe(
map((organizations) => organizations.find((org) => org.id === orgId)), map(([organization, collections]) => {
map((org) => { if (!organization) {
if (!org) {
return null; return null;
} }
const orgCollections = collections.filter((c) => c.organizationId === org.id); const orgCollections = collections.filter(
const hasReachedLimit = org.maxCollections === orgCollections.length; (collection: Collection) => collection.organizationId === organization.id,
);
const hasReachedLimit = organization.maxCollections === orgCollections.length;
if (hasReachedLimit) { if (hasReachedLimit) {
return { return {

View File

@@ -285,7 +285,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.keyService.clearKeys(userId), this.keyService.clearKeys(userId),
this.cipherService.clear(userId), this.cipherService.clear(userId),
this.folderService.clear(userId), this.folderService.clear(userId),
this.collectionService.clear(userId),
this.biometricStateService.logout(userId), this.biometricStateService.logout(userId),
]); ]);

View File

@@ -9,7 +9,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -68,7 +68,6 @@ export class BulkDeleteDialogComponent {
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams, @Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
private dialogRef: DialogRef<BulkDeleteDialogResult>, private dialogRef: DialogRef<BulkDeleteDialogResult>,
private cipherService: CipherService, private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private apiService: ApiService, private apiService: ApiService,
private collectionService: CollectionService, private collectionService: CollectionService,
@@ -116,7 +115,11 @@ export class BulkDeleteDialogComponent {
}); });
} }
if (this.collections.length) { if (this.collections.length) {
await this.collectionService.delete(this.collections.map((c) => c.id)); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.collectionService.delete(
this.collections.map((c) => c.id as CollectionId),
userId,
);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: null,

View File

@@ -78,7 +78,7 @@ describe("vault filter service", () => {
configService.getFeatureFlag$.mockReturnValue(of(true)); configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.memberOrganizations$.mockReturnValue(organizations); organizationService.memberOrganizations$.mockReturnValue(organizations);
folderService.folderViews$.mockReturnValue(folderViews); folderService.folderViews$.mockReturnValue(folderViews);
collectionService.decryptedCollections$ = collectionViews; collectionService.decryptedCollections$.mockReturnValue(collectionViews);
policyService.policyAppliesToUser$ policyService.policyAppliesToUser$
.calledWith(PolicyType.OrganizationDataOwnership, mockUserId) .calledWith(PolicyType.OrganizationDataOwnership, mockUserId)
.mockReturnValue(organizationDataOwnershipPolicy); .mockReturnValue(organizationDataOwnershipPolicy);

View File

@@ -4,7 +4,6 @@ import { Injectable } from "@angular/core";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
combineLatestWith,
filter, filter,
firstValueFrom, firstValueFrom,
map, map,
@@ -100,13 +99,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
map((folders) => this.buildFolderTree(folders)), map((folders) => this.buildFolderTree(folders)),
); );
filteredCollections$: Observable<CollectionView[]> = filteredCollections$: Observable<CollectionView[]> = combineLatest([
this.collectionService.decryptedCollections$.pipe( this.accountService.activeAccount$.pipe(
combineLatestWith(this._organizationFilter), getUserId,
switchMap(([collections, org]) => { switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
return this.filterCollections(collections, org); ),
}), this._organizationFilter,
); ]).pipe(switchMap(([collections, org]) => this.filterCollections(collections, org)));
collectionTree$: Observable<TreeNode<CollectionFilter>> = combineLatest([ collectionTree$: Observable<TreeNode<CollectionFilter>> = combineLatest([
this.filteredCollections$, this.filteredCollections$,

View File

@@ -334,7 +334,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}); });
const filter$ = this.routedVaultFilterService.filter$; const filter$ = this.routedVaultFilterService.filter$;
const allCollections$ = this.collectionService.decryptedCollections$;
const allCollections$ = this.collectionService.decryptedCollections$(activeUserId);
const nestedCollections$ = allCollections$.pipe( const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)), map((collections) => getNestedCollectionTree(collections)),
); );
@@ -861,7 +862,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
if (result.collection) { if (result.collection) {
// Update CollectionService with the new collection // Update CollectionService with the new collection
const c = new CollectionData(result.collection as CollectionDetailsResponse); const c = new CollectionData(result.collection as CollectionDetailsResponse);
await this.collectionService.upsert(c); const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
await this.collectionService.upsert(c, activeUserId);
} }
this.refresh(); this.refresh();
} }
@@ -878,20 +882,23 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}); });
const result = await lastValueFrom(dialog.closed); const result = await lastValueFrom(dialog.closed);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (result.action === CollectionDialogAction.Saved) { if (result.action === CollectionDialogAction.Saved) {
if (result.collection) { if (result.collection) {
// Update CollectionService with the new collection // Update CollectionService with the new collection
const c = new CollectionData(result.collection as CollectionDetailsResponse); const c = new CollectionData(result.collection as CollectionDetailsResponse);
await this.collectionService.upsert(c); await this.collectionService.upsert(c, activeUserId);
} }
this.refresh(); this.refresh();
} else if (result.action === CollectionDialogAction.Deleted) { } else if (result.action === CollectionDialogAction.Deleted) {
await this.collectionService.delete(result.collection?.id); const parent = this.selectedCollection?.parent;
this.refresh();
// Navigate away if we deleted the collection we were viewing // Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === c?.id) { const navigateAway = this.selectedCollection && this.selectedCollection.node.id === c.id;
await this.collectionService.delete([result.collection?.id as CollectionId], activeUserId);
this.refresh();
if (navigateAway) {
await this.router.navigate([], { await this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, queryParams: { collectionId: parent?.node.id ?? null },
queryParamsHandling: "merge", queryParamsHandling: "merge",
replaceUrl: true, replaceUrl: true,
}); });
@@ -916,18 +923,22 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return; return;
} }
try { try {
const parent = this.selectedCollection?.parent;
// Navigate away if we deleted the collection we were viewing
const navigateAway =
this.selectedCollection && this.selectedCollection.node.id === collection.id;
await this.apiService.deleteCollection(collection.organizationId, collection.id); await this.apiService.deleteCollection(collection.organizationId, collection.id);
await this.collectionService.delete(collection.id); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.collectionService.delete([collection.id as CollectionId], activeUserId);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: null,
message: this.i18nService.t("deletedCollectionId", collection.name), message: this.i18nService.t("deletedCollectionId", collection.name),
}); });
// Navigate away if we deleted the collection we were viewing if (navigateAway) {
if (this.selectedCollection?.node.id === collection.id) {
await this.router.navigate([], { await this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, queryParams: { collectionId: parent?.node.id ?? null },
queryParamsHandling: "merge", queryParamsHandling: "merge",
replaceUrl: true, replaceUrl: true,
}); });

View File

@@ -1,15 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common"; import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models"; import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService { export abstract class CollectionAdminService {
getAll: (organizationId: string) => Promise<CollectionAdminView[]>; abstract getAll: (organizationId: string) => Promise<CollectionAdminView[]>;
get: (organizationId: string, collectionId: string) => Promise<CollectionAdminView | undefined>; abstract get: (
save: (collection: CollectionAdminView) => Promise<CollectionDetailsResponse>; organizationId: string,
delete: (organizationId: string, collectionId: string) => Promise<void>; collectionId: string,
bulkAssignAccess: ( ) => Promise<CollectionAdminView | undefined>;
abstract save: (
collection: CollectionAdminView,
userId: UserId,
) => Promise<CollectionDetailsResponse>;
abstract delete: (organizationId: string, collectionId: string) => Promise<void>;
abstract bulkAssignAccess: (
organizationId: string, organizationId: string,
collectionIds: string[], collectionIds: string[],
users: CollectionAccessSelectionView[], users: CollectionAccessSelectionView[],

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -9,27 +7,25 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models"; import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService { export abstract class CollectionService {
encryptedCollections$: Observable<Collection[]>; abstract encryptedCollections$: (userId: UserId) => Observable<Collection[] | null>;
decryptedCollections$: Observable<CollectionView[]>; abstract decryptedCollections$: (userId: UserId) => Observable<CollectionView[]>;
abstract upsert: (collection: CollectionData, userId: UserId) => Promise<any>;
clearActiveUserCache: () => Promise<void>; abstract replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
encrypt: (model: CollectionView) => Promise<Collection>;
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
/** /**
* @deprecated This method will soon be made private * @deprecated This method will soon be made private, use `decryptedCollections$` instead.
* See PM-12375
*/ */
decryptMany: ( abstract decryptMany$: (
collections: Collection[], collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>, orgKeys: Record<OrganizationId, OrgKey>,
) => Promise<CollectionView[]>; ) => Observable<CollectionView[]>;
get: (id: string) => Promise<Collection>; abstract delete: (ids: CollectionId[], userId: UserId) => Promise<any>;
getAll: () => Promise<Collection[]>; abstract encrypt: (model: CollectionView, userId: UserId) => Promise<Collection>;
getAllDecrypted: () => Promise<CollectionView[]>; /**
getAllNested: (collections?: CollectionView[]) => Promise<TreeNode<CollectionView>[]>; * Transforms the input CollectionViews into TreeNodes
getNested: (id: string) => Promise<TreeNode<CollectionView>>; */
upsert: (collection: CollectionData | CollectionData[]) => Promise<any>; abstract getAllNested: (collections: CollectionView[]) => TreeNode<CollectionView>[];
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>; /*
clear: (userId?: string) => Promise<void>; * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
delete: (id: string | string[]) => Promise<any>; */
abstract getNested: (collections: CollectionView[], id: string) => TreeNode<CollectionView>;
} }

View File

@@ -1,43 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { 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 { CollectionData, Collection, CollectionView } from "../models";
export abstract class vNextCollectionService {
encryptedCollections$: (userId: UserId) => Observable<Collection[]>;
decryptedCollections$: (userId: UserId) => Observable<CollectionView[]>;
upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise<any>;
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
/**
* Clear decrypted state without affecting encrypted state.
* Used for locking the vault.
*/
clearDecryptedState: (userId: UserId) => Promise<void>;
/**
* Clear decrypted and encrypted state.
* Used for logging out.
*/
clear: (userId: UserId) => Promise<void>;
delete: (id: string | string[], userId: UserId) => Promise<any>;
encrypt: (model: CollectionView) => Promise<Collection>;
/**
* @deprecated This method will soon be made private, use `decryptedCollections$` instead.
*/
decryptMany: (
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey> | null,
) => Promise<CollectionView[]>;
/**
* Transforms the input CollectionViews into TreeNodes
*/
getAllNested: (collections: CollectionView[]) => TreeNode<CollectionView>[];
/**
* Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
*/
getNested: (collections: CollectionView[], id: string) => TreeNode<CollectionView>;
}

View File

@@ -26,7 +26,10 @@ export class CollectionData {
this.type = response.type; this.type = response.type;
} }
static fromJSON(obj: Jsonify<CollectionData>) { static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
if (obj == null) {
return null;
}
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj); return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
} }
} }

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base"; import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
import { OrgKey } from "@bitwarden/common/types/key"; import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data"; import { CollectionData } from "./collection.data";
@@ -15,16 +13,16 @@ export const CollectionTypes = {
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes]; export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain { export class Collection extends Domain {
id: string; id: string | undefined;
organizationId: string; organizationId: string | undefined;
name: EncString; name: EncString | undefined;
externalId: string; externalId: string | undefined;
readOnly: boolean; readOnly: boolean = false;
hidePasswords: boolean; hidePasswords: boolean = false;
manage: boolean; manage: boolean = false;
type: CollectionType; type: CollectionType = CollectionTypes.SharedCollection;
constructor(obj?: CollectionData) { constructor(obj?: CollectionData | null) {
super(); super();
if (obj == null) { if (obj == null) {
return; return;
@@ -51,8 +49,8 @@ export class Collection extends Domain {
return this.decryptObj<Collection, CollectionView>( return this.decryptObj<Collection, CollectionView>(
this, this,
new CollectionView(this), new CollectionView(this),
["name"], ["name"] as EncryptableKeys<Collection, CollectionView>[],
this.organizationId, this.organizationId ?? null,
orgKey, orgKey,
); );
} }

View File

@@ -12,7 +12,7 @@ export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject { export class CollectionView implements View, ITreeNodeObject {
id: string | undefined; id: string | undefined;
organizationId: string | undefined; organizationId: string | undefined;
name: string | undefined; name: string = "";
externalId: string | undefined; externalId: string | undefined;
// readOnly applies to the items within a collection // readOnly applies to the items within a collection
readOnly: boolean = false; readOnly: boolean = false;

View File

@@ -0,0 +1,28 @@
import { Jsonify } from "type-fest";
import {
COLLECTION_DISK,
COLLECTION_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<
CollectionData | null,
CollectionId
>(COLLECTION_DISK, "collections", {
deserializer: (jsonData: Jsonify<CollectionData | null>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
});
export const DECRYPTED_COLLECTION_DATA_KEY = new UserKeyDefinition<CollectionView[] | null>(
COLLECTION_MEMORY,
"decryptedCollections",
{
deserializer: (obj: Jsonify<CollectionView[] | null>) =>
obj?.map((f) => CollectionView.fromJSON(f)) ?? null,
clearOn: ["logout", "lock"],
},
);

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions"; import { CollectionAdminService, CollectionService } from "../abstractions";
@@ -55,7 +57,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
return view; return view;
} }
async save(collection: CollectionAdminView): Promise<CollectionDetailsResponse> { async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection); const request = await this.encrypt(collection);
let response: CollectionDetailsResponse; let response: CollectionDetailsResponse;
@@ -71,9 +73,9 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
} }
if (response.assigned) { if (response.assigned) {
await this.collectionService.upsert(new CollectionData(response)); await this.collectionService.upsert(new CollectionData(response), userId);
} else { } else {
await this.collectionService.delete(collection.id); await this.collectionService.delete([collection.id as CollectionId], userId);
} }
return response; return response;

View File

@@ -1,10 +1,11 @@
import { mock } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs"; import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { import {
FakeStateProvider, FakeStateProvider,
@@ -16,124 +17,382 @@ import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/gu
import { OrgKey } from "@bitwarden/common/types/key"; import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { CollectionData } from "../models"; import { CollectionData, CollectionView } from "../models";
import { import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
DefaultCollectionService, import { DefaultCollectionService } from "./default-collection.service";
ENCRYPTED_COLLECTION_DATA_KEY,
} from "./default-collection.service";
describe("DefaultCollectionService", () => { describe("DefaultCollectionService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let stateProvider: FakeStateProvider;
let userId: UserId;
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
let collectionService: DefaultCollectionService;
beforeEach(() => {
userId = Utils.newGuid() as UserId;
keyService = mock();
encryptService = mock();
i18nService = mock();
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
cryptoKeys = new ReplaySubject(1);
keyService.orgKeys$.mockReturnValue(cryptoKeys);
// Set up mock decryption
encryptService.decryptString
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Arrange i18nService so that sorting algorithm doesn't throw
i18nService.collator = null;
collectionService = new DefaultCollectionService(
keyService,
encryptService,
i18nService,
stateProvider,
);
});
afterEach(() => { afterEach(() => {
delete (window as any).bitwardenContainerService; delete (window as any).bitwardenContainerService;
}); });
describe("decryptedCollections$", () => { describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => { it("emits decrypted collections from state", async () => {
// Arrange test collections // Arrange test data
const org1 = Utils.newGuid() as OrganizationId; const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId; const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1); const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
const collection2 = collectionDataFactory(org2); const collection2 = collectionDataFactory(org2);
// Arrange state provider // Arrange dependencies
const fakeStateProvider = mockStateProvider(); await setEncryptedState([collection1, collection2]);
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, { cryptoKeys.next({
[collection1.id]: collection1, [org1]: orgKey1,
[collection2.id]: collection2, [org2]: orgKey2,
}); });
// Arrange cryptoService - orgKeys and mock decryption const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new DefaultCollectionService( // Assert emitted values
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const result = await firstValueFrom(collectionService.decryptedCollections$);
expect(result.length).toBe(2); expect(result.length).toBe(2);
expect(result[0]).toMatchObject({ expect(result).toContainPartialObjects([
id: collection1.id, {
name: "DECRYPTED_STRING", id: collection1.id,
}); name: "DEC_NAME_" + collection1.id,
expect(result[1]).toMatchObject({ },
id: collection2.id, {
name: "DECRYPTED_STRING", id: collection2.id,
}); name: "DEC_NAME_" + collection2.id,
},
]);
// Assert that the correct org keys were used for each encrypted string
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection1.name)),
orgKey1,
);
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection2.name)),
orgKey2,
);
});
it("emits decrypted collections from in-memory state when available", async () => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionViewDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionViewDataFactory(org2);
await setDecryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
// Assert emitted values
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
{
id: collection2.id,
name: "DEC_NAME_" + collection2.id,
},
]);
// Ensure that the returned data came from the in-memory state, rather than from decryption.
expect(encryptService.decryptString).not.toHaveBeenCalled();
}); });
it("handles null collection state", async () => { it("handles null collection state", async () => {
// Arrange test collections // Arrange dependencies
await setEncryptedState(null);
cryptoKeys.next({});
const encryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(encryptedCollections).toBe(null);
});
it("handles undefined orgKeys", (done) => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId; const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId; const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionDataFactory(org2);
// Arrange state provider // Emit a non-null value after the first undefined value has propagated
const fakeStateProvider = mockStateProvider(); // This will cause the collections to emit, calling done()
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null); cryptoKeys.pipe(first()).subscribe((val) => {
cryptoKeys.next({});
});
// Arrange cryptoService - orgKeys and mock decryption collectionService
const cryptoService = mockCryptoService(); .decryptedCollections$(userId)
cryptoService.orgKeys$.mockReturnValue( .pipe(takeWhile((val) => val.length != 2))
of({ .subscribe({ complete: () => done() });
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new DefaultCollectionService( // Arrange dependencies
cryptoService, void setEncryptedState([collection1, collection2]).then(() => {
mock<EncryptService>(), // Act: emit undefined
mockI18nService(), cryptoKeys.next(undefined);
fakeStateProvider, keyService.activeUserOrgKeys$ = of(undefined);
); });
});
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$); it("Decrypts one time for multiple simultaneous callers", async () => {
expect(decryptedCollections.length).toBe(0); const decryptedMock: CollectionView[] = [{ id: "col1" }] as CollectionView[];
const decryptManySpy = jest
.spyOn(collectionService, "decryptMany$")
.mockReturnValue(of(decryptedMock));
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$); jest
expect(encryptedCollections.length).toBe(0); .spyOn(collectionService as any, "encryptedCollections$")
.mockReturnValue(of([{ id: "enc1" }]));
jest.spyOn(keyService, "orgKeys$").mockReturnValue(of({ key: "fake-key" }));
// Simulate multiple subscribers
const obs1 = collectionService.decryptedCollections$(userId);
const obs2 = collectionService.decryptedCollections$(userId);
const obs3 = collectionService.decryptedCollections$(userId);
await firstValueFrom(combineLatest([obs1, obs2, obs3]));
// Expect decryptMany$ to be called only once
expect(decryptManySpy).toHaveBeenCalledTimes(1);
}); });
}); });
describe("encryptedCollections$", () => {
it("emits encrypted collections from state", async () => {
// Arrange test data
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
]);
});
it("handles null collection state", async () => {
await setEncryptedState(null);
const decryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(decryptedCollections).toBe(null);
});
});
describe("upsert", () => {
it("upserts to existing collections", async () => {
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
await setEncryptedState([collection1]);
cryptoKeys.next({
[collection1.organizationId]: orgKey1,
});
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
});
await collectionService.upsert(updatedCollection1, userId);
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedResult!.length).toBe(1);
expect(encryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
},
]);
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedResult.length).toBe(1);
expect(decryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: "UPDATED_DEC_NAME_" + collection1.id,
},
]);
});
it("upserts to a null state", async () => {
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
cryptoKeys.next({
[collection1.organizationId]: orgKey1,
});
await setEncryptedState(null);
await collectionService.upsert(collection1, userId);
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedResult!.length).toBe(1);
expect(encryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
]);
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedResult.length).toBe(1);
expect(decryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
]);
});
});
describe("replace", () => {
it("replaces all collections", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
const newCollection3 = collectionDataFactory();
await collectionService.replace(
{
[newCollection3.id]: newCollection3,
},
userId,
);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
});
describe("delete", () => {
it("deletes a collection", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
await collectionService.delete([collection1.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(1);
expect(result![0]).toMatchObject({ id: collection2.id });
});
it("deletes several collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
const collection3 = collectionDataFactory();
await setEncryptedState([collection1, collection2, collection3]);
await collectionService.delete([collection1.id, collection3.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(1);
expect(result![0]).toMatchObject({ id: collection2.id });
});
it("handles null collections", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.delete([collection1.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(0);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
userId,
);
const setDecryptedState = (collectionViews: CollectionView[] | null) =>
stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collectionViews, userId);
}); });
const mockI18nService = () => { const collectionDataFactory = (orgId?: OrganizationId) => {
const i18nService = mock<I18nService>();
i18nService.collator = null; // this is a mock only, avoid use of this object
return i18nService;
};
const mockStateProvider = () => {
const userId = Utils.newGuid() as UserId;
return new FakeStateProvider(mockAccountServiceWith(userId));
};
const mockCryptoService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptString
.calledWith(expect.any(EncString), expect.anything())
.mockResolvedValue("DECRYPTED_STRING");
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
return keyService;
};
const collectionDataFactory = (orgId: OrganizationId) => {
const collection = new CollectionData({} as any); const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId; collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId; collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collection.name = makeEncString("ENC_STRING").encryptedString; collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString ?? "";
return collection; return collection;
}; };
function collectionViewDataFactory(orgId?: OrganizationId): CollectionView {
const collectionView = new CollectionView();
collectionView.id = Utils.newGuid() as CollectionId;
collectionView.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collectionView.name = "DEC_NAME_" + collectionView.id;
return collectionView;
}

View File

@@ -1,113 +1,193 @@
// FIXME: Update this file to be type safe and remove this and next line import {
// @ts-strict-ignore combineLatest,
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs"; delayWhen,
import { Jsonify } from "type-fest"; filter,
firstValueFrom,
from,
map,
NEVER,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
ActiveUserState,
COLLECTION_DATA,
DeriveDefinition,
DerivedState,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key"; import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions"; import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models"; import { Collection, CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>( import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
},
);
const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
[Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
CollectionView[],
{ collectionService: DefaultCollectionService }
>(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async ([collections, orgKeys], { collectionService }) => {
if (collections == null) {
return [];
}
const data = Object.values(collections).map((c) => new Collection(c));
return await collectionService.decryptMany(data, orgKeys);
},
});
const NestingDelimiter = "/"; const NestingDelimiter = "/";
export class DefaultCollectionService implements CollectionService { export class DefaultCollectionService implements CollectionService {
private encryptedCollectionDataState: ActiveUserState<Record<CollectionId, CollectionData>>;
encryptedCollections$: Observable<Collection[]>;
private decryptedCollectionDataState: DerivedState<CollectionView[]>;
decryptedCollections$: Observable<CollectionView[]>;
decryptedCollectionViews$(ids: CollectionId[]): Observable<CollectionView[]> {
return this.decryptedCollections$.pipe(
map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))),
);
}
constructor( constructor(
private keyService: KeyService, private keyService: KeyService,
private encryptService: EncryptService, private encryptService: EncryptService,
private i18nService: I18nService, private i18nService: I18nService,
protected stateProvider: StateProvider, protected stateProvider: StateProvider,
) { ) {}
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe( private collectionViewCache = new Map<UserId, Observable<CollectionView[]>>();
/**
* @returns a SingleUserState for encrypted collection data.
*/
private encryptedState(
userId: UserId,
): SingleUserState<Record<CollectionId, CollectionData | null>> {
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) => { map((collections) => {
if (collections == null) { if (collections == null) {
return []; return null;
} }
return Object.values(collections).map((c) => new Collection(c)); return Object.values(collections).map((c) => new Collection(c));
}), }),
); );
}
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe( decryptedCollections$(userId: UserId): Observable<CollectionView[]> {
switchMap(([userId, collectionData]) => const cachedResult = this.collectionViewCache.get(userId);
combineLatest([of(collectionData), this.keyService.orgKeys$(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)),
),
), ),
shareReplay({ refCount: false, bufferSize: 1 }),
); );
this.decryptedCollectionDataState = this.stateProvider.getDerived(
encryptedCollectionsWithKeys,
DECRYPTED_COLLECTION_DATA_KEY,
{ collectionService: this },
);
this.decryptedCollections$ = this.decryptedCollectionDataState.state$;
} }
async clearActiveUserCache(): Promise<void> { async upsert(toUpdate: CollectionData, userId: UserId): Promise<void> {
await this.decryptedCollectionDataState.forceValue(null); 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$([new Collection(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 encrypt(model: CollectionView): Promise<Collection> { 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> {
if (model.organizationId == null) { if (model.organizationId == null) {
throw new Error("Collection has no organization id."); throw new Error("Collection has no organization id.");
} }
const key = await this.keyService.getOrgKey(model.organizationId);
if (key == null) { const key = await firstValueFrom(
throw new Error("No key for this collection's organization."); this.keyService.orgKeys$(userId).pipe(
} filter((orgKeys) => !!orgKeys),
map((k) => k[model.organizationId as OrganizationId]),
),
);
const collection = new Collection(); const collection = new Collection();
collection.id = model.id; collection.id = model.id;
collection.organizationId = model.organizationId; collection.organizationId = model.organizationId;
@@ -117,58 +197,37 @@ export class DefaultCollectionService implements CollectionService {
return collection; return collection;
} }
// TODO: this should be private and orgKeys should be required. // TODO: this should be private.
// See https://bitwarden.atlassian.net/browse/PM-12375 // See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany( decryptMany$(
collections: Collection[], collections: Collection[] | null,
orgKeys?: Record<OrganizationId, OrgKey>, orgKeys: Record<OrganizationId, OrgKey>,
): Promise<CollectionView[]> { ): Observable<CollectionView[]> {
if (collections == null || collections.length === 0) { if (collections === null || collections.length == 0 || orgKeys === null) {
return []; return of([]);
} }
const decCollections: CollectionView[] = [];
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); const decCollections: Observable<CollectionView>[] = [];
const promises: Promise<any>[] = [];
collections.forEach((collection) => { collections.forEach((collection) => {
promises.push( decCollections.push(
collection from(collection.decrypt(orgKeys[collection.organizationId as OrganizationId])),
.decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)),
); );
}); });
await Promise.all(promises);
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
}
async get(id: string): Promise<Collection> { return combineLatest(decCollections).pipe(
return ( map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
(await firstValueFrom(
this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))),
)) ?? null
); );
} }
async getAll(): Promise<Collection[]> { getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
return await firstValueFrom(this.encryptedCollections$);
}
async getAllDecrypted(): Promise<CollectionView[]> {
return await firstValueFrom(this.decryptedCollections$);
}
async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
if (collections == null) {
collections = await this.getAllDecrypted();
}
const nodes: TreeNode<CollectionView>[] = []; const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => { collections.forEach((c) => {
const collectionCopy = new CollectionView(); const collectionCopy = new CollectionView();
collectionCopy.id = c.id; collectionCopy.id = c.id;
collectionCopy.organizationId = c.organizationId; collectionCopy.organizationId = c.organizationId;
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
}); });
return nodes; return nodes;
} }
@@ -177,58 +236,23 @@ export class DefaultCollectionService implements CollectionService {
* @deprecated August 30 2022: Moved to new Vault Filter Service * @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated * Remove when Desktop and Browser are updated
*/ */
async getNested(id: string): Promise<TreeNode<CollectionView>> { getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
const collections = await this.getAllNested(); const nestedCollections = this.getAllNested(collections);
return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode<CollectionView>; return ServiceUtils.getTreeNodeObjectFromList(
nestedCollections,
id,
) as TreeNode<CollectionView>;
} }
async upsert(toUpdate: CollectionData | CollectionData[]): Promise<void> { /**
if (toUpdate == null) { * Sets the decrypted collections state for a user.
return; * @param collections the decrypted collections
} * @param userId the user id
await this.encryptedCollectionDataState.update((collections) => { */
if (collections == null) { private async setDecryptedCollections(
collections = {}; collections: CollectionView[],
} userId: UserId,
if (Array.isArray(toUpdate)) { ): Promise<void> {
toUpdate.forEach((c) => { await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId);
collections[c.id] = c;
});
} else {
collections[toUpdate.id] = toUpdate;
}
return collections;
});
}
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.stateProvider
.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY)
.update(() => collections);
}
async clear(userId?: UserId): Promise<void> {
if (userId == null) {
await this.encryptedCollectionDataState.update(() => null);
await this.decryptedCollectionDataState.forceValue(null);
} else {
await this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY).update(() => null);
}
}
async delete(id: CollectionId | CollectionId[]): Promise<any> {
await this.encryptedCollectionDataState.update((collections) => {
if (collections == null) {
collections = {};
}
if (typeof id === "string") {
delete collections[id];
} else {
(id as CollectionId[]).forEach((i) => {
delete collections[i];
});
}
return collections;
});
} }
} }

View File

@@ -1,345 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import {
FakeStateProvider,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData } from "../models";
import { DefaultvNextCollectionService } from "./default-vnext-collection.service";
import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state";
describe("DefaultvNextCollectionService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let stateProvider: FakeStateProvider;
let userId: UserId;
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
let collectionService: DefaultvNextCollectionService;
beforeEach(() => {
userId = Utils.newGuid() as UserId;
keyService = mock();
encryptService = mock();
i18nService = mock();
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
cryptoKeys = new ReplaySubject(1);
keyService.orgKeys$.mockReturnValue(cryptoKeys);
// Set up mock decryption
encryptService.decryptString
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Arrange i18nService so that sorting algorithm doesn't throw
i18nService.collator = null;
collectionService = new DefaultvNextCollectionService(
keyService,
encryptService,
i18nService,
stateProvider,
);
});
afterEach(() => {
delete (window as any).bitwardenContainerService;
});
describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
const collection2 = collectionDataFactory(org2);
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
cryptoKeys.next({
[org1]: orgKey1,
[org2]: orgKey2,
});
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
// Assert emitted values
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
{
id: collection2.id,
name: "DEC_NAME_" + collection2.id,
},
]);
// Assert that the correct org keys were used for each encrypted string
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection1.name)),
orgKey1,
);
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection2.name)),
orgKey2,
);
});
it("handles null collection state", async () => {
// Arrange dependencies
await setEncryptedState(null);
cryptoKeys.next({});
const encryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(encryptedCollections.length).toBe(0);
});
it("handles undefined orgKeys", (done) => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionDataFactory(org2);
// Emit a non-null value after the first undefined value has propagated
// This will cause the collections to emit, calling done()
cryptoKeys.pipe(first()).subscribe((val) => {
cryptoKeys.next({});
});
collectionService
.decryptedCollections$(userId)
.pipe(takeWhile((val) => val.length != 2))
.subscribe({ complete: () => done() });
// Arrange dependencies
void setEncryptedState([collection1, collection2]).then(() => {
// Act: emit undefined
cryptoKeys.next(undefined);
keyService.activeUserOrgKeys$ = of(undefined);
});
});
});
describe("encryptedCollections$", () => {
it("emits encrypted collections from state", async () => {
// Arrange test data
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
]);
});
it("handles null collection state", async () => {
await setEncryptedState(null);
const decryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(decryptedCollections.length).toBe(0);
});
});
describe("upsert", () => {
it("upserts to existing collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
});
const newCollection3 = collectionDataFactory();
await collectionService.upsert([updatedCollection1, newCollection3], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(3);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
it("upserts to a null state", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.upsert(collection1, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
]);
});
});
describe("replace", () => {
it("replaces all collections", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
const newCollection3 = collectionDataFactory();
await collectionService.replace(
{
[newCollection3.id]: newCollection3,
},
userId,
);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
});
it("clearDecryptedState", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
await collectionService.clearDecryptedState(userId);
// Encrypted state remains
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedState.length).toEqual(2);
// Decrypted state is cleared
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedState.length).toEqual(0);
});
it("clear", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
cryptoKeys.next({});
await collectionService.clear(userId);
// Encrypted state is cleared
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedState.length).toEqual(0);
// Decrypted state is cleared
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedState.length).toEqual(0);
});
describe("delete", () => {
it("deletes a collection", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
await collectionService.delete(collection1.id, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(1);
expect(result[0]).toMatchObject({ id: collection2.id });
});
it("deletes several collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
const collection3 = collectionDataFactory();
await setEncryptedState([collection1, collection2, collection3]);
await collectionService.delete([collection1.id, collection3.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(1);
expect(result[0]).toMatchObject({ id: collection2.id });
});
it("handles null collections", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.delete(collection1.id, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(0);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
userId,
);
});
const collectionDataFactory = (orgId?: OrganizationId) => {
const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString;
return collection;
};

View File

@@ -1,194 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, filter, firstValueFrom, map } 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 { StateProvider, DerivedState } 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 { vNextCollectionService } from "../abstractions/vnext-collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import {
DECRYPTED_COLLECTION_DATA_KEY,
ENCRYPTED_COLLECTION_DATA_KEY,
} from "./vnext-collection.state";
const NestingDelimiter = "/";
export class DefaultvNextCollectionService implements vNextCollectionService {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
protected stateProvider: StateProvider,
) {}
encryptedCollections$(userId: UserId) {
return this.encryptedState(userId).state$.pipe(
map((collections) => {
if (collections == null) {
return [];
}
return Object.values(collections).map((c) => new Collection(c));
}),
);
}
decryptedCollections$(userId: UserId) {
return this.decryptedState(userId).state$.pipe(map((collections) => collections ?? []));
}
async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
if (Array.isArray(toUpdate)) {
toUpdate.forEach((c) => {
collections[c.id] = c;
});
} else {
collections[toUpdate.id] = toUpdate;
}
return collections;
});
}
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => collections);
}
async clearDecryptedState(userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
await this.decryptedState(userId).forceValue([]);
}
async clear(userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => null);
// This will propagate from the encrypted state update, but by doing it explicitly
// the promise doesn't resolve until the update is complete.
await this.decryptedState(userId).forceValue([]);
}
async delete(id: CollectionId | CollectionId[], userId: UserId): Promise<any> {
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
if (typeof id === "string") {
delete collections[id];
} else {
(id as CollectionId[]).forEach((i) => {
delete collections[i];
});
}
return collections;
});
}
async encrypt(model: CollectionView): Promise<Collection> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.keyService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const collection = new Collection();
collection.id = model.id;
collection.organizationId = model.organizationId;
collection.readOnly = model.readOnly;
collection.externalId = model.externalId;
collection.name = await this.encryptService.encryptString(model.name, key);
return collection;
}
// TODO: this should be private and orgKeys should be required.
// See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey> | null,
): Promise<CollectionView[]> {
if (collections == null || collections.length === 0) {
return [];
}
const decCollections: CollectionView[] = [];
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$);
const promises: Promise<any>[] = [];
collections.forEach((collection) => {
promises.push(
collection
.decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)),
);
});
await Promise.all(promises);
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
}
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => {
const collectionCopy = new CollectionView();
collectionCopy.id = c.id;
collectionCopy.organizationId = c.organizationId;
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>;
}
/**
* @returns a SingleUserState for encrypted collection data.
*/
private encryptedState(userId: UserId) {
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
}
/**
* @returns a SingleUserState for decrypted collection data.
*/
private decryptedState(userId: UserId): DerivedState<CollectionView[]> {
const encryptedCollectionsWithKeys$ = combineLatest([
this.encryptedCollections$(userId),
// orgKeys$ can emit null during brief moments on unlock and lock/logout, we want to ignore those intermediate states
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)),
]);
return this.stateProvider.getDerived(
encryptedCollectionsWithKeys$,
DECRYPTED_COLLECTION_DATA_KEY,
{
collectionService: this,
},
);
}
}

View File

@@ -1,36 +0,0 @@
import { Jsonify } from "type-fest";
import {
COLLECTION_DATA,
DeriveDefinition,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { vNextCollectionService } from "../abstractions/vnext-collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
},
);
export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
[Collection[], Record<OrganizationId, OrgKey> | null],
CollectionView[],
{ collectionService: vNextCollectionService }
>(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async ([collections, orgKeys], { collectionService }) => {
if (collections == null) {
return [];
}
return await collectionService.decryptMany(collections, orgKeys);
},
});

View File

@@ -27,7 +27,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, userId), this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherListViews$(userId), this.cipherService.cipherListViews$(userId),
this.organizationService.organizations$(userId), this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$, this.collectionService.decryptedCollections$(userId),
]).pipe( ]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => { switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasContents = !(ciphers == null || ciphers.length === 0); const vaultHasContents = !(ciphers == null || ciphers.length === 0);

View File

@@ -27,7 +27,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, userId), this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId), this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId), this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$, this.collectionService.decryptedCollections$(userId),
]).pipe( ]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => { switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1; const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;

View File

@@ -109,7 +109,12 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
} }
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> { async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
const storedCollections = await this.collectionService.getAllDecrypted(); const storedCollections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
),
);
const orgs = await this.buildOrganizations(); const orgs = await this.buildOrganizations();
const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag( const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.CreateDefaultLocation, FeatureFlag.CreateDefaultLocation,

View File

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

View File

@@ -13,9 +13,9 @@ export const getById = <TId, T extends { id: TId }>(id: TId) =>
* @param id The IDs of the objects to return. * @param id The IDs of the objects to return.
* @returns An array containing objects with matching IDs, or an empty array if there are no matching objects. * @returns An array containing objects with matching IDs, or an empty array if there are no matching objects.
*/ */
export const getByIds = <TId, T extends { id: TId }>(ids: TId[]) => { export const getByIds = <TId, T extends { id: TId | undefined }>(ids: TId[]) => {
const idSet = new Set(ids); const idSet = new Set(ids.filter((id) => id != null));
return map<T[], T[]>((objects) => { return map<T[], T[]>((objects) => {
return objects.filter((o) => idSet.has(o.id)); return objects.filter((o) => o.id && idSet.has(o.id));
}); });
}; };

View File

@@ -14,7 +14,7 @@ export type DecryptedObject<
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>; > = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
// extracts shared keys from the domain and view types // extracts shared keys from the domain and view types
type EncryptableKeys<D extends Domain, V extends View> = (keyof D & export type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
ConditionalKeys<D, EncString | null>) & ConditionalKeys<D, EncString | null>) &
(keyof V & ConditionalKeys<V, string | null>); (keyof V & ConditionalKeys<V, string | null>);

View File

@@ -164,9 +164,13 @@ export const SEND_ACCESS_AUTH_MEMORY = new StateDefinition("sendAccessAuth", "me
// Vault // Vault
export const COLLECTION_DATA = new StateDefinition("collection", "disk", { export const COLLECTION_DISK = new StateDefinition("collection", "disk", {
web: "memory", web: "memory",
}); });
export const COLLECTION_MEMORY = new StateDefinition("decryptedCollections", "memory", {
browser: "memory-large-object",
});
export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" }); export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" });
export const FOLDER_MEMORY = new StateDefinition("decryptedFolders", "memory", { export const FOLDER_MEMORY = new StateDefinition("decryptedFolders", "memory", {
browser: "memory-large-object", browser: "memory-large-object",

View File

@@ -172,7 +172,11 @@ export abstract class CoreSyncService implements SyncService {
notification.collectionIds != null && notification.collectionIds != null &&
notification.collectionIds.length > 0 notification.collectionIds.length > 0
) { ) {
const collections = await this.collectionService.getAll(); const collections = await firstValueFrom(
this.collectionService
.encryptedCollections$(userId)
.pipe(map((collections) => collections ?? [])),
);
if (collections != null) { if (collections != null) {
for (let i = 0; i < collections.length; i++) { for (let i = 0; i < collections.length; i++) {
if (notification.collectionIds.indexOf(collections[i].id) > -1) { if (notification.collectionIds.indexOf(collections[i].id) > -1) {

View File

@@ -119,7 +119,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => { cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false); expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled(); expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done(); done();
}); });
}); });
@@ -133,7 +133,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => { cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(true); expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled(); expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done(); done();
}); });
}); });
@@ -198,6 +198,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => { cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false); expect(result).toBe(false);
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done(); done();
}); });
}); });
@@ -251,7 +252,7 @@ describe("CipherAuthorizationService", () => {
createMockCollection("col1", true), createMockCollection("col1", true),
createMockCollection("col2", false), createMockCollection("col2", false),
]; ];
mockCollectionService.decryptedCollectionViews$.mockReturnValue( mockCollectionService.decryptedCollections$.mockReturnValue(
of(allCollections as CollectionView[]), of(allCollections as CollectionView[]),
); );
@@ -270,7 +271,7 @@ describe("CipherAuthorizationService", () => {
createMockCollection("col1", false), createMockCollection("col1", false),
createMockCollection("col2", false), createMockCollection("col2", false),
]; ];
mockCollectionService.decryptedCollectionViews$.mockReturnValue( mockCollectionService.decryptedCollections$.mockReturnValue(
of(allCollections as CollectionView[]), of(allCollections as CollectionView[]),
); );

View File

@@ -1,11 +1,11 @@
import { map, Observable, of, shareReplay, switchMap } from "rxjs"; import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CollectionId } from "@bitwarden/common/types/guid"; import { getByIds } from "@bitwarden/common/platform/misc";
import { getUserId } from "../../auth/services/account.service"; import { getUserId } from "../../auth/services/account.service";
import { CipherLike } from "../types/cipher-like"; import { CipherLike } from "../types/cipher-like";
@@ -125,8 +125,11 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
return of(true); return of(true);
} }
return this.organization$(cipher).pipe( return combineLatest([
switchMap((organization) => { this.organization$(cipher),
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([organization, userId]) => {
// Admins and custom users can always clone when in the Admin Console // Admins and custom users can always clone when in the Admin Console
if ( if (
isAdminConsoleAction && isAdminConsoleAction &&
@@ -136,9 +139,10 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
return of(true); return of(true);
} }
return this.collectionService return this.collectionService.decryptedCollections$(userId).pipe(
.decryptedCollectionViews$(cipher.collectionIds as CollectionId[]) getByIds(cipher.collectionIds),
.pipe(map((allCollections) => allCollections.some((collection) => collection.manage))); map((allCollections) => allCollections.some((collection) => collection.manage)),
);
}), }),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),
); );

View File

@@ -300,7 +300,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
// Retrieve all organizations a user is a member of and has collections they can manage // Retrieve all organizations a user is a member of and has collections they can manage
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.organizations$ = this.organizationService.memberOrganizations$(userId).pipe( this.organizations$ = this.organizationService.memberOrganizations$(userId).pipe(
combineLatestWith(this.collectionService.decryptedCollections$), combineLatestWith(this.collectionService.decryptedCollections$(userId)),
map(([organizations, collections]) => map(([organizations, collections]) =>
organizations organizations
.filter((org) => collections.some((c) => c.organizationId === org.id && c.manage)) .filter((org) => collections.some((c) => c.organizationId === org.id && c.manage))
@@ -318,15 +318,15 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
} }
if (value) { if (value) {
this.collections$ = Utils.asyncToObservable(() => this.collections$ = this.collectionService
this.collectionService .decryptedCollections$(userId)
.getAllDecrypted() .pipe(
.then((decryptedCollections) => map((decryptedCollections) =>
decryptedCollections decryptedCollections
.filter((c2) => c2.organizationId === value && c2.manage) .filter((c2) => c2.organizationId === value && c2.manage)
.sort(Utils.getSortFunction(this.i18nService, "name")), .sort(Utils.getSortFunction(this.i18nService, "name")),
), ),
); );
} }
}); });
this.formGroup.controls.vaultSelector.setValue("myVault"); this.formGroup.controls.vaultSelector.setValue("myVault");

View File

@@ -406,7 +406,7 @@ export class ImportService implements ImportServiceAbstraction {
if (importResult.collections != null) { if (importResult.collections != null) {
for (let i = 0; i < importResult.collections.length; i++) { for (let i = 0; i < importResult.collections.length; i++) {
importResult.collections[i].organizationId = organizationId; importResult.collections[i].organizationId = organizationId;
const c = await this.collectionService.encrypt(importResult.collections[i]); const c = await this.collectionService.encrypt(importResult.collections[i], activeUserId);
request.collections.push(new CollectionWithIdRequest(c)); request.collections.push(new CollectionWithIdRequest(c));
} }
} }

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import * as papa from "papaparse"; import * as papa from "papaparse";
import { firstValueFrom } from "rxjs"; import { firstValueFrom, map } from "rxjs";
import { import {
CollectionService, CollectionService,
@@ -225,15 +225,8 @@ export class OrganizationVaultExportService
): Promise<string> { ): Promise<string> {
let decCiphers: CipherView[] = []; let decCiphers: CipherView[] = [];
let allDecCiphers: CipherView[] = []; let allDecCiphers: CipherView[] = [];
let decCollections: CollectionView[] = [];
const promises = []; const promises = [];
promises.push(
this.collectionService.getAllDecrypted().then(async (collections) => {
decCollections = collections.filter((c) => c.organizationId == organizationId && c.manage);
}),
);
promises.push( promises.push(
this.cipherService.getAllDecrypted(activeUserId).then((ciphers) => { this.cipherService.getAllDecrypted(activeUserId).then((ciphers) => {
allDecCiphers = ciphers; allDecCiphers = ciphers;
@@ -241,6 +234,16 @@ export class OrganizationVaultExportService
); );
await Promise.all(promises); await Promise.all(promises);
const decCollections: CollectionView[] = await firstValueFrom(
this.collectionService
.decryptedCollections$(activeUserId)
.pipe(
map((collections) =>
collections.filter((c) => c.organizationId == organizationId && c.manage),
),
),
);
const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$); const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$);
decCiphers = allDecCiphers.filter( decCiphers = allDecCiphers.filter(
@@ -263,15 +266,8 @@ export class OrganizationVaultExportService
): Promise<string> { ): Promise<string> {
let encCiphers: Cipher[] = []; let encCiphers: Cipher[] = [];
let allCiphers: Cipher[] = []; let allCiphers: Cipher[] = [];
let encCollections: Collection[] = [];
const promises = []; const promises = [];
promises.push(
this.collectionService.getAll().then((collections) => {
encCollections = collections.filter((c) => c.organizationId == organizationId && c.manage);
}),
);
promises.push( promises.push(
this.cipherService.getAll(activeUserId).then((ciphers) => { this.cipherService.getAll(activeUserId).then((ciphers) => {
allCiphers = ciphers; allCiphers = ciphers;
@@ -280,6 +276,15 @@ export class OrganizationVaultExportService
await Promise.all(promises); await Promise.all(promises);
const encCollections: Collection[] = await firstValueFrom(
this.collectionService.encryptedCollections$(activeUserId).pipe(
map((collections) => collections ?? []),
map((collections) =>
collections.filter((c) => c.organizationId == organizationId && c.manage),
),
),
);
const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$); const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$);
encCiphers = allCiphers.filter( encCiphers = allCiphers.filter(

View File

@@ -272,25 +272,29 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
return; return;
} }
this.organizations$ = combineLatest({ this.organizations$ = this.accountService.activeAccount$
collections: this.collectionService.decryptedCollections$, .pipe(
memberOrganizations: this.accountService.activeAccount$.pipe(
getUserId, getUserId,
switchMap((userId) => this.organizationService.memberOrganizations$(userId)), switchMap((userId) =>
), combineLatest({
}).pipe( collections: this.collectionService.decryptedCollections$(userId),
map(({ collections, memberOrganizations }) => { memberOrganizations: this.organizationService.memberOrganizations$(userId),
const managedCollectionsOrgIds = new Set( }),
collections.filter((c) => c.manage).map((c) => c.organizationId), ),
); )
// Filter organizations that exist in managedCollectionsOrgIds .pipe(
const filteredOrgs = memberOrganizations.filter((org) => map(({ collections, memberOrganizations }) => {
managedCollectionsOrgIds.has(org.id), const managedCollectionsOrgIds = new Set(
); collections.filter((c) => c.manage).map((c) => c.organizationId),
// Sort the filtered organizations based on the name );
return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name")); // Filter organizations that exist in managedCollectionsOrgIds
}), const filteredOrgs = memberOrganizations.filter((org) =>
); managedCollectionsOrgIds.has(org.id),
);
// Sort the filtered organizations based on the name
return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name"));
}),
);
combineLatest([ combineLatest([
this.disablePersonalVaultExportPolicy$, this.disablePersonalVaultExportPolicy$,

View File

@@ -48,9 +48,10 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService {
await firstValueFrom( await firstValueFrom(
combineLatest([ combineLatest([
this.organizations$(activeUserId), this.organizations$(activeUserId),
this.collectionService.encryptedCollections$.pipe( this.collectionService.encryptedCollections$(activeUserId).pipe(
map((collections) => collections ?? []),
switchMap((c) => switchMap((c) =>
this.collectionService.decryptedCollections$.pipe( this.collectionService.decryptedCollections$(activeUserId).pipe(
filter((d) => d.length === c.length), // Ensure all collections have been decrypted filter((d) => d.length === c.length), // Ensure all collections have been decrypted
), ),
), ),

View File

@@ -16,7 +16,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { isCardExpired } from "@bitwarden/common/autofill/utils";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId, CollectionId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; import { getByIds } from "@bitwarden/common/platform/misc";
import { CipherId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@@ -143,6 +144,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
return; return;
} }
const userId = await firstValueFrom(this.activeUserId$);
// Load collections if not provided and the cipher has collectionIds // Load collections if not provided and the cipher has collectionIds
if ( if (
this.cipher.collectionIds && this.cipher.collectionIds &&
@@ -150,14 +153,12 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
(!this.collections || this.collections.length === 0) (!this.collections || this.collections.length === 0)
) { ) {
this.collections = await firstValueFrom( this.collections = await firstValueFrom(
this.collectionService.decryptedCollectionViews$( this.collectionService
this.cipher.collectionIds as CollectionId[], .decryptedCollections$(userId)
), .pipe(getByIds(this.cipher.collectionIds)),
); );
} }
const userId = await firstValueFrom(this.activeUserId$);
if (this.cipher.organizationId) { if (this.cipher.organizationId) {
this.organization$ = this.organizationService this.organization$ = this.organizationService
.organizations$(userId) .organizations$(userId)

View File

@@ -435,12 +435,14 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
* @returns An observable of the collections for the organization. * @returns An observable of the collections for the organization.
*/ */
private getCollectionsForOrganization(orgId: OrganizationId): Observable<CollectionView[]> { private getCollectionsForOrganization(orgId: OrganizationId): Observable<CollectionView[]> {
return combineLatest([ return this.accountService.activeAccount$.pipe(
this.collectionService.decryptedCollections$, getUserId,
this.accountService.activeAccount$.pipe( switchMap((userId) =>
switchMap((account) => this.organizationService.organizations$(account?.id)), combineLatest([
this.collectionService.decryptedCollections$(userId),
this.organizationService.organizations$(userId),
]),
), ),
]).pipe(
map(([collections, organizations]) => { map(([collections, organizations]) => {
const org = organizations.find((o) => o.id === orgId); const org = organizations.find((o) => o.id === orgId);
this.orgName = org.name; this.orgName = org.name;