mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +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:
@@ -1030,8 +1030,12 @@ 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(
|
||||||
|
getUserId,
|
||||||
|
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
|
||||||
|
map((collections) =>
|
||||||
|
collections.reduce<CollectionView[]>((acc, collection) => {
|
||||||
if (collection.organizationId === message?.orgId) {
|
if (collection.organizationId === message?.orgId) {
|
||||||
acc.push({
|
acc.push({
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
@@ -1040,8 +1044,9 @@ export default class NotificationBackground {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
}, []),
|
||||||
[],
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return collections;
|
return collections;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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$);
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
getUserId,
|
||||||
|
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||||
|
switchMap((orgKeys) => this.collectionService.decryptMany$(collections, orgKeys)),
|
||||||
|
map((collections) => {
|
||||||
const collectionMap: Record<string, CollectionView> = {};
|
const collectionMap: Record<string, CollectionView> = {};
|
||||||
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
|
collections.forEach((c) => (collectionMap[c.id] = c));
|
||||||
|
|
||||||
return collectionMap;
|
return collectionMap;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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$,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
id: collection1.id,
|
||||||
name: "DECRYPTED_STRING",
|
name: "DEC_NAME_" + collection1.id,
|
||||||
});
|
},
|
||||||
expect(result[1]).toMatchObject({
|
{
|
||||||
id: collection2.id,
|
id: collection2.id,
|
||||||
name: "DECRYPTED_STRING",
|
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>(),
|
// Arrange dependencies
|
||||||
}),
|
void setEncryptedState([collection1, collection2]).then(() => {
|
||||||
|
// Act: emit undefined
|
||||||
|
cryptoKeys.next(undefined);
|
||||||
|
keyService.activeUserOrgKeys$ = of(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Decrypts one time for multiple simultaneous callers", async () => {
|
||||||
|
const decryptedMock: CollectionView[] = [{ id: "col1" }] as CollectionView[];
|
||||||
|
const decryptManySpy = jest
|
||||||
|
.spyOn(collectionService, "decryptMany$")
|
||||||
|
.mockReturnValue(of(decryptedMock));
|
||||||
|
|
||||||
|
jest
|
||||||
|
.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 collectionService = new DefaultCollectionService(
|
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
|
||||||
cryptoService,
|
expect(result!.length).toBe(1);
|
||||||
mock<EncryptService>(),
|
expect(result).toContainPartialObjects([
|
||||||
mockI18nService(),
|
{
|
||||||
fakeStateProvider,
|
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 decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
|
const setDecryptedState = (collectionViews: CollectionView[] | null) =>
|
||||||
expect(decryptedCollections.length).toBe(0);
|
stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collectionViews, userId);
|
||||||
|
|
||||||
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
|
|
||||||
expect(encryptedCollections.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async encrypt(model: CollectionView): Promise<Collection> {
|
if (!decryptedCollections?.length) {
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedCollection = decryptedCollections[0];
|
||||||
|
const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
collections[existingIndex] = decryptedCollection;
|
||||||
|
} else {
|
||||||
|
collections.push(decryptedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collections;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
|
||||||
|
await this.encryptedState(userId).update(() => collections);
|
||||||
|
await this.decryptedState(userId).update(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(ids: CollectionId[], userId: UserId): Promise<any> {
|
||||||
|
await this.encryptedState(userId).update((collections) => {
|
||||||
|
if (collections == null) {
|
||||||
|
collections = {};
|
||||||
|
}
|
||||||
|
ids.forEach((i) => {
|
||||||
|
delete collections[i];
|
||||||
|
});
|
||||||
|
return collections;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.decryptedState(userId).update((collections) => {
|
||||||
|
if (collections == null) {
|
||||||
|
collections = [];
|
||||||
|
}
|
||||||
|
ids.forEach((i) => {
|
||||||
|
if (collections?.length) {
|
||||||
|
collections = collections.filter((c) => c.id != i) ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return collections;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,10 +318,10 @@ 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")),
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -272,13 +272,17 @@ 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({
|
||||||
|
collections: this.collectionService.decryptedCollections$(userId),
|
||||||
|
memberOrganizations: this.organizationService.memberOrganizations$(userId),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}).pipe(
|
)
|
||||||
|
.pipe(
|
||||||
map(({ collections, memberOrganizations }) => {
|
map(({ collections, memberOrganizations }) => {
|
||||||
const managedCollectionsOrgIds = new Set(
|
const managedCollectionsOrgIds = new Set(
|
||||||
collections.filter((c) => c.manage).map((c) => c.organizationId),
|
collections.filter((c) => c.manage).map((c) => c.organizationId),
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user