1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +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(
message: NotificationBackgroundExtensionMessage,
): Promise<CollectionView[]> {
const collections = (await this.collectionService.getAllDecrypted()).reduce<CollectionView[]>(
(acc, collection) => {
if (collection.organizationId === message?.orgId) {
acc.push({
id: collection.id,
name: collection.name,
organizationId: collection.organizationId,
});
}
return acc;
},
[],
const collections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
map((collections) =>
collections.reduce<CollectionView[]>((acc, collection) => {
if (collection.organizationId === message?.orgId) {
acc.push({
id: collection.id,
name: collection.name,
organizationId: collection.organizationId,
});
}
return acc;
}, []),
),
),
);
return collections;
}

View File

@@ -1620,7 +1620,6 @@ export default class MainBackground {
this.keyService.clearKeys(userBeingLoggedOut),
this.cipherService.clear(userBeingLoggedOut),
this.folderService.clear(userBeingLoggedOut),
this.collectionService.clear(userBeingLoggedOut),
this.vaultTimeoutSettingsService.clear(userBeingLoggedOut),
this.vaultFilterService.clear(),
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 { JslibModule } from "@bitwarden/angular/jslib.module";
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
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())
.subscribe(([cipherView, collections]) => {
let availableCollections = collections;

View File

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

View File

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

View File

@@ -72,6 +72,11 @@ export class VaultPopupItemsService {
private organizations$ = this.activeUserId$.pipe(
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
* 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(
switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe(
combineLatest([this.organizations$, this.decryptedCollections$]).pipe(
map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
@@ -291,7 +296,7 @@ export class VaultPopupItemsService {
*/
deletedCiphers$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe(
combineLatest([this.organizations$, this.decryptedCollections$]).pipe(
map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
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 { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
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 { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
@@ -58,7 +59,7 @@ describe("VaultPopupListFiltersService", () => {
};
const collectionService = {
decryptedCollections$,
decryptedCollections$: () => decryptedCollections$,
getAllNested: () => Promise.resolve([]),
} as unknown as CollectionService;
@@ -106,7 +107,7 @@ describe("VaultPopupListFiltersService", () => {
signal: jest.fn(() => mockCachedSignal),
};
collectionService.getAllNested = () => Promise.resolve([]);
collectionService.getAllNested = () => [];
TestBed.configureTestingModule({
providers: [
{
@@ -382,14 +383,7 @@ describe("VaultPopupListFiltersService", () => {
beforeEach(() => {
decryptedCollections$.next(testCollections);
collectionService.getAllNested = () =>
Promise.resolve(
testCollections.map((c) => ({
children: [],
node: c,
parent: null,
})),
);
collectionService.getAllNested = () => testCollections.map((c) => new TreeNode(c, null));
});
it("returns all collections", (done) => {
@@ -755,15 +749,13 @@ function createSeededVaultPopupListFiltersService(
} as any;
const collectionServiceMock = {
decryptedCollections$: seededCollections$,
decryptedCollections$: () => seededCollections$,
getAllNested: () =>
Promise.resolve(
seededCollections$.value.map((c) => ({
children: [],
node: c,
parent: null,
})),
),
seededCollections$.value.map((c) => ({
children: [],
node: c,
parent: null,
})),
} as any;
const folderServiceMock = {

View File

@@ -6,7 +6,6 @@ import {
debounceTime,
distinctUntilChanged,
filter,
from,
map,
Observable,
shareReplay,
@@ -446,7 +445,7 @@ export class VaultPopupListFiltersService {
this.filters$.pipe(
distinctUntilChanged((prev, curr) => prev.organization?.id === curr.organization?.id),
),
this.collectionService.decryptedCollections$,
this.collectionService.decryptedCollections$(userId),
this.organizationService.memberOrganizations$(userId),
this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation),
]),
@@ -463,16 +462,11 @@ export class VaultPopupListFiltersService {
}
return sortDefaultCollections(filtered, orgs, this.i18nService.collator);
}),
switchMap((collections) => {
return from(this.collectionService.getAllNested(collections)).pipe(
map(
(nested) =>
new DynamicTreeNode<CollectionView>({
fullList: collections,
nestedList: nested,
}),
),
);
map((fullList) => {
return new DynamicTreeNode<CollectionView>({
fullList,
nestedList: this.collectionService.getAllNested(fullList),
});
}),
map((tree) =>
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 { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
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 { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -442,8 +443,11 @@ export class GetCommand extends DownloadCommand {
private async getCollection(id: string) {
let decCollection: CollectionView = null;
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
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) {
const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$);
decCollection = await collection.decrypt(
@@ -451,7 +455,9 @@ export class GetCommand extends DownloadCommand {
);
}
} else if (id.trim() !== "") {
let collections = await this.collectionService.getAllDecrypted();
let collections = await firstValueFrom(
this.collectionService.decryptedCollections$(activeUserId),
);
collections = CliUtils.searchCollections(collections, id);
if (collections.length > 1) {
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 { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
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 { OrganizationUserResponse } from "../admin-console/models/response/organization-user.response";
@@ -42,6 +43,7 @@ export class ListCommand {
private apiService: ApiService,
private eventCollectionService: EventCollectionService,
private accountService: AccountService,
private keyService: KeyService,
private cliRestrictedItemTypesService: CliRestrictedItemTypesService,
) {}
@@ -158,7 +160,10 @@ export class ListCommand {
}
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) {
collections = collections.filter((c) => {
@@ -178,13 +183,13 @@ export class ListCommand {
}
private async listOrganizationCollections(options: Options) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (!userId) {
return Response.badRequest("No user found.");
}
@@ -207,7 +212,13 @@ export class ListCommand {
const collections = response.data
.filter((c) => c.organizationId === options.organizationId)
.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() !== "") {
decCollections = CliUtils.searchCollections(decCollections, options.search);
}

View File

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

View File

@@ -901,7 +901,6 @@ export class ServiceContainer {
this.keyService.clearKeys(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(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.eventCollectionService,
this.serviceContainer.accountService,
this.serviceContainer.keyService,
this.serviceContainer.cliRestrictedItemTypesService,
);
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.cipherService.clear(userBeingLoggedOut);
await this.folderService.clear(userBeingLoggedOut);
await this.collectionService.clear(userBeingLoggedOut);
await this.vaultTimeoutSettingsService.clear(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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { 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 { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
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.collectionService.decryptedCollections$
if (!this.activeUserId) {
throw new Error("No user found.");
}
this.collectionService
.decryptedCollections$(this.activeUserId)
.pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((collections) => {
this.allCollections = collections;
@@ -701,9 +707,17 @@ export class VaultV2Component<C extends CipherViewLike>
this.cipherId = null;
this.action = "view";
await this.vaultItemsComponent?.refresh().catch(() => {});
if (!this.activeUserId) {
throw new Error("No userId provided.");
}
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.cipher = cipher;
if (this.activeUserId) {

View File

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

View File

@@ -11,6 +11,7 @@ import {
from,
lastValueFrom,
map,
Observable,
switchMap,
tap,
} from "rxjs";
@@ -25,10 +26,13 @@ import {
CollectionView,
} from "@bitwarden/admin-console/common";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService, TableDataSource, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { GroupDetailsView, InternalGroupApiService as GroupService } from "../core";
@@ -100,6 +104,8 @@ export class GroupsComponent {
private logService: LogService,
private collectionService: CollectionService,
private toastService: ToastService,
private keyService: KeyService,
private accountService: AccountService,
) {
this.route.params
.pipe(
@@ -244,16 +250,22 @@ export class GroupsComponent {
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(
(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
const collectionMap: Record<string, CollectionView> = {};
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
switchMap((orgKeys) => this.collectionService.decryptMany$(collections, orgKeys)),
map((collections) => {
const collectionMap: Record<string, CollectionView> = {};
collections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
}),
);
}
}

View File

@@ -13,6 +13,7 @@ import {
Observable,
shareReplay,
switchMap,
withLatestFrom,
tap,
} from "rxjs";
@@ -307,17 +308,27 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const collectionMap = new Map<string, string>();
const response = await this.apiService.getCollections(this.organization.id);
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
const response = from(this.apiService.getCollections(this.organization.id)).pipe(
map((res) =>
res.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> {

View File

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

View File

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

View File

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

View File

@@ -285,7 +285,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.keyService.clearKeys(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(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 { getUserId } from "@bitwarden/common/auth/services/account.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 { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -68,7 +68,6 @@ export class BulkDeleteDialogComponent {
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
private dialogRef: DialogRef<BulkDeleteDialogResult>,
private cipherService: CipherService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private apiService: ApiService,
private collectionService: CollectionService,
@@ -116,7 +115,11 @@ export class BulkDeleteDialogComponent {
});
}
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({
variant: "success",
title: null,

View File

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

View File

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

View File

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