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 d0dffd481f..3e1067a1da 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 447d52fefa..67c2fe45b2 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 14023c377d..cba1afdba6 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 90a5930881..570c4b6158 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 595b5caf8d..71e0827947 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 b2f9a10cf3..1ef5df456f 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 e748c11844..ef21f2aa0f 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 07e9b998a4..5920ec200d 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]); + }); + } }