From e98cbed437b91543173473e09fc0fa7d9e4326e2 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Sat, 5 Aug 2023 00:05:14 +0200 Subject: [PATCH] [AC-1119] [PM-1923] [AC-701] Import into a specified folder or collection (#5683) * Migrate callouts to the CL ones * Add folder/collection selection * Use bitTypography as page header/title * Migrate submit button to CL * Migrate fileSelector and fileContents * Add ability to import into an existing folder/collection Extended import.service and abstraction to receive importTarget on import() Pass selectedImportTarget to importService.import() Wrote unit tests * Added vault selector, folders/collections selection logic and component library to the import * Revert changes to the already migrated CL fileSelector, fileContents and header/title * Fix fileContents input and spacing to submit button * Use id's instead of name for tghe targetSelector * Remove unneeded empty line * Fix import into existing folder/collection Map ciphers with no folder/no collection to the new rootFolder when selected by the user Modified and added unit tests * Added CL to fileSelector and fileInput on vault import * Added reactive forms and new selector logic to import vault * Added new texts on Import Vault * Corrected logic on enable targetSelector * Removing target selector from being required * Fixed imports after messing up a merge conflict * Set No-Folder as default * Show icons (folder/collection) on targetSelector * Add icons to vaultSelector * Set `My Vault` as default of the vaultSelector * Updates labels based on feedback from design * Set `My Vault` as default of the vaultSelector pt2 * Improvements to reactive forms on import.component * Only disabling individual vault import on PersonalOwnership policy * Use import destination instead of import location * Add hint to folder/collection dropdown * Removed required attribute as provided by formGroup * Display no collection option same as no folder * Show error on org import with unassigned items Only admins can have unassigned items (items with no collection) If these are present in a export/backup file, they should still be imported, to not break existing behaviour. This is limited to admins. When a member of an org does not set a root collection (no collection option) and any items are unassigned an error message is shown and the import is aborted. * Removed for-attribute from bit-labels * Removed bitInput from bit-selects * Updates to messages.json after PR feedback * Removed name-attribute from bit-selects * Removed unneeded variables * Removed unneeded line break * Migrate form to use bitSubmit Rename old submit() to performImport() Create submit arrow function calling performImport() (which can be overridden/called by org-import.component) Remove #form and ngNativeValidate Add bitSubmit and bitFormButton directives Remove now unneeded loading variable * Added await to super.performImport() * Move form check into submit * AC-1558 - Enable org import with remove individual vault policy Hide the `My Vault` entry when policy is active Always check if the policy applies and disable the formGroup if no vault-target is selectable * [AC-1549] Import page design updates (#5933) * Display select folder/collection in targetSelector Filter the no-folder entry from the folderViews-observable Add labels for the targetSelector placeholders * Update importTargetHint and remove importTargetOrgHint * Update language on importUnassignedItemsError * Add help icon with link to the import documentation --------- Co-authored-by: Andre Rosado --- .../import-export/org-import.component.ts | 21 ++- .../tools/import-export/import.component.html | 156 +++++++++++------- .../tools/import-export/import.component.ts | 144 +++++++++++++--- apps/web/src/locales/en/messages.json | 25 +++ .../organization.service.abstraction.ts | 6 + .../services/import.service.abstraction.ts | 4 +- .../src/services/import.service.spec.ts | 104 ++++++++++++ libs/importer/src/services/import.service.ts | 81 ++++++++- 8 files changed, 455 insertions(+), 86 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/tools/import-export/org-import.component.ts b/apps/web/src/app/admin-console/organizations/tools/import-export/org-import.component.ts index d0dffd481f0..3e1067a1da8 100644 --- a/apps/web/src/app/admin-console/organizations/tools/import-export/org-import.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/import-export/org-import.component.ts @@ -1,4 +1,5 @@ import { Component } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { switchMap, takeUntil } from "rxjs/operators"; @@ -13,6 +14,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ImportServiceAbstraction } from "@bitwarden/importer"; @@ -37,11 +40,14 @@ export class OrganizationImportComponent extends ImportComponent { private route: ActivatedRoute, platformUtilsService: PlatformUtilsService, policyService: PolicyService, - private organizationService: OrganizationService, + organizationService: OrganizationService, logService: LogService, modalService: ModalService, syncService: SyncService, - dialogService: DialogServiceAbstraction + dialogService: DialogServiceAbstraction, + folderService: FolderService, + collectionService: CollectionService, + formBuilder: FormBuilder ) { super( i18nService, @@ -52,7 +58,11 @@ export class OrganizationImportComponent extends ImportComponent { logService, modalService, syncService, - dialogService + dialogService, + folderService, + collectionService, + organizationService, + formBuilder ); } @@ -74,11 +84,10 @@ export class OrganizationImportComponent extends ImportComponent { await this.router.navigate(["organizations", this.organizationId, "vault"]); } else { this.fileSelected = null; - this.fileContents = ""; } } - async submit() { + protected async performImport() { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "warning" }, content: { key: "importWarning", placeholders: [this.organization.name] }, @@ -88,6 +97,6 @@ export class OrganizationImportComponent extends ImportComponent { if (!confirmed) { return; } - super.submit(); + await super.performImport(); } } diff --git a/apps/web/src/app/tools/import-export/import.component.html b/apps/web/src/app/tools/import-export/import.component.html index 447d52fefa7..67c2fe45b2a 100644 --- a/apps/web/src/app/tools/import-export/import.component.html +++ b/apps/web/src/app/tools/import-export/import.component.html @@ -1,20 +1,69 @@ - - +

{{ "importData" | i18n }}

+ + {{ "personalOwnershipPolicyInEffectImports" | i18n }} -
-
+ + + + {{ "importDestination" | i18n }} + + + + + + + + + + + + {{ organizationId ? ("collection" | i18n) : ("folder" | i18n) }} + + + + + + + + + + + {{ + "importTargetHint" + | i18n + : (organizationId ? ("collection" | i18n | lowercase) : ("folder" | i18n | lowercase)) + }} + + - 1. {{ "selectFormat" | i18n }} - + {{ "fileFormat" | i18n }} + @@ -22,7 +71,7 @@ - + See detailed instructions on our help site at @@ -292,53 +341,48 @@ Log in to "https://vault.passky.org" → "Import & Export" → "Export" in the Passky section. ("Backup" is unsupported as it is encrypted). - -
-
-
- -
-
- - {{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }} -
- -
+ + + {{ "selectImportFile" | i18n }} +
+ + {{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
-
-
- + + + + {{ "orCopyPasteFileContents" | i18n }} -
+ diff --git a/apps/web/src/app/tools/import-export/import.component.ts b/apps/web/src/app/tools/import-export/import.component.ts index 14023c377de..cba1afdba68 100644 --- a/apps/web/src/app/tools/import-export/import.component.ts +++ b/apps/web/src/app/tools/import-export/import.component.ts @@ -1,18 +1,29 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import * as JSZip from "jszip"; -import { Subject, lastValueFrom } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs"; +import { map, takeUntil } from "rxjs/operators"; import Swal, { SweetAlertIcon } from "sweetalert2"; import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { + canAccessImportExport, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ImportOption, ImportResult, @@ -30,15 +41,31 @@ export class ImportComponent implements OnInit, OnDestroy { featuredImportOptions: ImportOption[]; importOptions: ImportOption[]; format: ImportType = null; - fileContents: string; fileSelected: File; - loading = false; + + folders$: Observable; + collections$: Observable; + organizations$: Observable; protected organizationId: string = null; protected destroy$ = new Subject(); private _importBlockedByPolicy = false; + formGroup = this.formBuilder.group({ + vaultSelector: [ + "myVault", + { + nonNullable: true, + validators: [Validators.required], + }, + ], + targetSelector: [null], + format: [null as ImportType | null, [Validators.required]], + fileContents: [], + file: [], + }); + constructor( protected i18nService: I18nService, protected importService: ImportServiceAbstraction, @@ -48,7 +75,11 @@ export class ImportComponent implements OnInit, OnDestroy { private logService: LogService, protected modalService: ModalService, protected syncService: SyncService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogServiceAbstraction, + protected folderService: FolderService, + protected collectionService: CollectionService, + protected organizationService: OrganizationService, + protected formBuilder: FormBuilder ) {} protected get importBlockedByPolicy(): boolean { @@ -65,15 +96,76 @@ export class ImportComponent implements OnInit, OnDestroy { ngOnInit() { this.setImportOptions(); - this.policyService - .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + this.organizations$ = concat( + this.organizationService.memberOrganizations$.pipe( + canAccessImportExport(this.i18nService), + map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))) + ) + ); + + combineLatest([ + this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + this.organizations$, + ]) .pipe(takeUntil(this.destroy$)) - .subscribe((policyAppliesToActiveUser) => { - this._importBlockedByPolicy = policyAppliesToActiveUser; + .subscribe(([policyApplies, orgs]) => { + this._importBlockedByPolicy = policyApplies; + if (policyApplies && orgs.length == 0) { + this.formGroup.disable(); + } + }); + + if (this.organizationId) { + this.formGroup.controls.vaultSelector.patchValue(this.organizationId); + this.formGroup.controls.vaultSelector.disable(); + + this.collections$ = Utils.asyncToObservable(() => + this.collectionService + .getAllDecrypted() + .then((c) => c.filter((c2) => c2.organizationId === this.organizationId)) + ); + } else { + // Filter out the `no folder`-item from folderViews$ + this.folders$ = this.folderService.folderViews$.pipe( + map((folders) => folders.filter((f) => f.id != null)) + ); + this.formGroup.controls.targetSelector.disable(); + + this.formGroup.controls.vaultSelector.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value) => { + this.organizationId = value != "myVault" ? value : undefined; + if (!this._importBlockedByPolicy) { + this.formGroup.controls.targetSelector.enable(); + } + if (value) { + this.collections$ = Utils.asyncToObservable(() => + this.collectionService + .getAllDecrypted() + .then((c) => c.filter((c2) => c2.organizationId === value)) + ); + } + }); + + this.formGroup.controls.vaultSelector.setValue("myVault"); + } + this.formGroup.controls.format.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value) => { + this.format = value; }); } - async submit() { + submit = async () => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + + await this.performImport(); + }; + + protected async performImport() { if (this.importBlockedByPolicy) { this.platformUtilsService.showToast( "error", @@ -83,8 +175,6 @@ export class ImportComponent implements OnInit, OnDestroy { return; } - this.loading = true; - const promptForPassword_callback = async () => { return await this.getFilePassword(); }; @@ -94,32 +184,28 @@ export class ImportComponent implements OnInit, OnDestroy { promptForPassword_callback, this.organizationId ); + if (importer === null) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), this.i18nService.t("selectFormat") ); - this.loading = false; return; } const fileEl = document.getElementById("file") as HTMLInputElement; const files = fileEl.files; - if ( - (files == null || files.length === 0) && - (this.fileContents == null || this.fileContents === "") - ) { + let fileContents = this.formGroup.controls.fileContents.value; + if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), this.i18nService.t("selectFile") ); - this.loading = false; return; } - let fileContents = this.fileContents; if (files != null && files.length > 0) { try { const content = await this.getFileContents(files[0]); @@ -137,12 +223,21 @@ export class ImportComponent implements OnInit, OnDestroy { this.i18nService.t("errorOccurred"), this.i18nService.t("selectFile") ); - this.loading = false; return; } + if (this.organizationId) { + await this.organizationService.get(this.organizationId)?.isAdmin; + } + try { - const result = await this.importService.import(importer, fileContents, this.organizationId); + const result = await this.importService.import( + importer, + fileContents, + this.organizationId, + this.formGroup.controls.targetSelector.value, + this.isUserAdmin(this.organizationId) + ); //No errors, display success message this.dialogService.open(ImportSuccessDialogComponent, { @@ -155,8 +250,13 @@ export class ImportComponent implements OnInit, OnDestroy { this.error(e); this.logService.error(e); } + } - this.loading = false; + private isUserAdmin(organizationId?: string): boolean { + if (!organizationId) { + return false; + } + return this.organizationService.get(this.organizationId)?.isAdmin; } getFormatInstructionTitle() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 90a5930881e..570c4b61588 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1293,6 +1293,31 @@ "importEncKeyError": { "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." }, + "importDestination": { + "message": "Import destination" + }, + "learnAboutImportOptions": { + "message": "Learn about your import options" + }, + "selectImportFolder": { + "message": "Select a folder" + }, + "selectImportCollection": { + "message": "Select a collection" + }, + "importTargetHint": { + "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", + "placeholders": { + "destination": { + "content": "$1", + "example": "folder or collection" + } + } + }, + "importUnassignedItemsError": { + "message": "File contains unassigned items." + }, "selectFormat": { "message": "Select the format of the import file" }, diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 595b5caf8d1..71e08279476 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -57,6 +57,12 @@ export function canAccessAdmin(i18nService: I18nService) { ); } +export function canAccessImportExport(i18nService: I18nService) { + return map((orgs) => + orgs.filter((org) => org.canAccessImportExport).sort(Utils.getSortFunction(i18nService, "name")) + ); +} + /** * Returns `true` if a user is a member of an organization (rather than only being a ProviderUser) * @deprecated Use organizationService.memberOrganizations$ instead diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index b2f9a10cf37..1ef5df456fe 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -9,7 +9,9 @@ export abstract class ImportServiceAbstraction { import: ( importer: Importer, fileContents: string, - organizationId?: string + organizationId?: string, + selectedImportTarget?: string, + isUserAdmin?: boolean ) => Promise; getImporter: ( format: ImportType | "bitwardenpasswordprotected", diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index e748c118443..ef21f2aa0f0 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -6,9 +6,12 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; +import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "./import-api.service.abstraction"; import { ImportService } from "./import.service"; @@ -72,4 +75,105 @@ describe("ImportService", () => { }); }); }); + + describe("setImportTarget", () => { + const organizationId = Utils.newGuid(); + + let importResult: ImportResult; + + beforeEach(() => { + importResult = new ImportResult(); + }); + + it("empty importTarget does nothing", async () => { + await importService["setImportTarget"](importResult, null, ""); + expect(importResult.folders.length).toBe(0); + }); + + const mockImportTargetFolder = new FolderView(); + mockImportTargetFolder.id = "myImportTarget"; + mockImportTargetFolder.name = "myImportTarget"; + + it("passing importTarget adds it to folders", async () => { + folderService.getAllDecryptedFromState.mockReturnValue( + Promise.resolve([mockImportTargetFolder]) + ); + + await importService["setImportTarget"](importResult, null, "myImportTarget"); + expect(importResult.folders.length).toBe(1); + expect(importResult.folders[0].name).toBe("myImportTarget"); + }); + + const mockFolder1 = new FolderView(); + mockFolder1.id = "folder1"; + mockFolder1.name = "folder1"; + + const mockFolder2 = new FolderView(); + mockFolder2.id = "folder2"; + mockFolder2.name = "folder2"; + + it("passing importTarget sets it as new root for all existing folders", async () => { + folderService.getAllDecryptedFromState.mockResolvedValue([ + mockImportTargetFolder, + mockFolder1, + mockFolder2, + ]); + + const myImportTarget = "myImportTarget"; + + importResult.folders.push(mockFolder1); + importResult.folders.push(mockFolder2); + + await importService["setImportTarget"](importResult, null, myImportTarget); + expect(importResult.folders.length).toBe(3); + expect(importResult.folders[0].name).toBe(myImportTarget); + expect(importResult.folders[1].name).toBe(`${myImportTarget}/${mockFolder1.name}`); + expect(importResult.folders[2].name).toBe(`${myImportTarget}/${mockFolder2.name}`); + }); + + const mockImportTargetCollection = new CollectionView(); + mockImportTargetCollection.id = "myImportTarget"; + mockImportTargetCollection.name = "myImportTarget"; + mockImportTargetCollection.organizationId = organizationId; + + const mockCollection1 = new CollectionView(); + mockCollection1.id = "collection1"; + mockCollection1.name = "collection1"; + mockCollection1.organizationId = organizationId; + + const mockCollection2 = new CollectionView(); + mockCollection1.id = "collection2"; + mockCollection1.name = "collection2"; + mockCollection1.organizationId = organizationId; + + it("passing importTarget adds it to collections", async () => { + collectionService.getAllDecrypted.mockResolvedValue([ + mockImportTargetCollection, + mockCollection1, + ]); + + await importService["setImportTarget"](importResult, organizationId, "myImportTarget"); + expect(importResult.collections.length).toBe(1); + expect(importResult.collections[0].name).toBe("myImportTarget"); + }); + + it("passing importTarget sets it as new root for all existing collections", async () => { + collectionService.getAllDecrypted.mockResolvedValue([ + mockImportTargetCollection, + mockCollection1, + mockCollection2, + ]); + + const myImportTarget = "myImportTarget"; + + importResult.collections.push(mockCollection1); + importResult.collections.push(mockCollection2); + + await importService["setImportTarget"](importResult, organizationId, myImportTarget); + expect(importResult.collections.length).toBe(3); + expect(importResult.collections[0].name).toBe(myImportTarget); + expect(importResult.collections[1].name).toBe(`${myImportTarget}/${mockCollection1.name}`); + expect(importResult.collections[2].name).toBe(`${myImportTarget}/${mockCollection2.name}`); + }); + }); }); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 07e9b998a43..5920ec200d4 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -13,6 +13,8 @@ import { CipherRequest } from "@bitwarden/common/vault/models/request/cipher.req import { CollectionWithIdRequest } from "@bitwarden/common/vault/models/request/collection-with-id.request"; import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { AscendoCsvImporter, @@ -106,7 +108,9 @@ export class ImportService implements ImportServiceAbstraction { async import( importer: Importer, fileContents: string, - organizationId: string = null + organizationId: string = null, + selectedImportTarget: string = null, + isUserAdmin: boolean ): Promise { let importResult: ImportResult; try { @@ -142,7 +146,17 @@ export class ImportService implements ImportServiceAbstraction { } } + if (organizationId && Utils.isNullOrWhitespace(selectedImportTarget) && !isUserAdmin) { + const hasUnassignedCollections = importResult.ciphers.some( + (c) => !Array.isArray(c.collectionIds) || c.collectionIds.length == 0 + ); + if (hasUnassignedCollections) { + throw new Error(this.i18nService.t("importUnassignedItemsError")); + } + } + try { + await this.setImportTarget(importResult, organizationId, selectedImportTarget); if (organizationId != null) { await this.handleOrganizationalImport(importResult, organizationId); } else { @@ -403,4 +417,69 @@ export class ImportService implements ImportServiceAbstraction { return new Error(errorMessage); } + + private async setImportTarget( + importResult: ImportResult, + organizationId: string, + importTarget: string + ) { + if (Utils.isNullOrWhitespace(importTarget)) { + return; + } + + if (organizationId) { + const collectionViews: CollectionView[] = await this.collectionService.getAllDecrypted(); + const targetCollection = collectionViews.find((c) => c.id === importTarget); + + const noCollectionRelationShips: [number, number][] = []; + importResult.ciphers.forEach((c, index) => { + if (!Array.isArray(c.collectionIds) || c.collectionIds.length == 0) { + c.collectionIds = [targetCollection.id]; + noCollectionRelationShips.push([index, 0]); + } + }); + + const collections: CollectionView[] = [...importResult.collections]; + importResult.collections = [targetCollection]; + collections.map((x) => { + const f = new CollectionView(); + f.name = `${targetCollection.name}/${x.name}`; + importResult.collections.push(f); + }); + + const relationships: [number, number][] = [...importResult.collectionRelationships]; + importResult.collectionRelationships = [...noCollectionRelationShips]; + relationships.map((x) => { + importResult.collectionRelationships.push([x[0], x[1] + 1]); + }); + + return; + } + + const folderViews = await this.folderService.getAllDecryptedFromState(); + const targetFolder = folderViews.find((f) => f.id === importTarget); + + const noFolderRelationShips: [number, number][] = []; + importResult.ciphers.forEach((c, index) => { + if (Utils.isNullOrEmpty(c.folderId)) { + c.folderId = targetFolder.id; + noFolderRelationShips.push([index, 0]); + } + }); + + const folders: FolderView[] = [...importResult.folders]; + importResult.folders = [targetFolder]; + folders.map((x) => { + const newFolderName = `${targetFolder.name}/${x.name}`; + const f = new FolderView(); + f.name = newFolderName; + importResult.folders.push(f); + }); + + const relationships: [number, number][] = [...importResult.folderRelationships]; + importResult.folderRelationships = [...noFolderRelationShips]; + relationships.map((x) => { + importResult.folderRelationships.push([x[0], x[1] + 1]); + }); + } }