1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-20 09:35:22 +02:00

[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 <arosado@bitwarden.com>
This commit is contained in:
Daniel James Smith 2023-08-05 00:05:14 +02:00 committed by GitHub
parent b89f31101f
commit e98cbed437
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 455 additions and 86 deletions

View File

@ -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();
}
}

View File

@ -1,20 +1,69 @@
<div class="page-header">
<h1>{{ "importData" | i18n }}</h1>
</div>
<app-callout type="info" *ngIf="importBlockedByPolicy">
<h1 bitTypography="h1">{{ "importData" | i18n }}</h1>
<bit-callout type="info" *ngIf="importBlockedByPolicy">
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
</app-callout>
<form #form (ngSubmit)="submit()" ngNativeValidate>
</bit-callout>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field>
<bit-label
>{{ "importDestination" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnAboutImportOptions' | i18n }}"
href="https://bitwarden.com/help/import-data/"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-select formControlName="vaultSelector">
<bit-option
*ngIf="!importBlockedByPolicy"
[label]="'myVault' | i18n"
value="myVault"
icon="bwi-user"
/>
<bit-option
*ngFor="let o of organizations$ | async"
[value]="o.id"
[label]="o.name"
icon="bwi-business"
/>
</bit-select>
</bit-form-field>
<bit-form-field>
<bit-label>{{ organizationId ? ("collection" | i18n) : ("folder" | i18n) }}</bit-label>
<bit-select formControlName="targetSelector">
<ng-container *ngIf="!organizationId">
<bit-option [value]="null" label="-- {{ 'selectImportFolder' | i18n }} --" />
<bit-option
*ngFor="let f of folders$ | async"
[value]="f.id"
[label]="f.name"
icon="bwi-folder"
/>
</ng-container>
<ng-container *ngIf="organizationId">
<bit-option [value]="null" label="-- {{ 'selectImportCollection' | i18n }} --" />
<bit-option
*ngFor="let c of collections$ | async"
[value]="c.id"
[label]="c.name"
icon="bwi-collection"
/>
</ng-container>
</bit-select>
<bit-hint>{{
"importTargetHint"
| i18n
: (organizationId ? ("collection" | i18n | lowercase) : ("folder" | i18n | lowercase))
}}</bit-hint>
</bit-form-field>
<bit-form-field class="tw-w-1/2">
<bit-label for="type">1. {{ "selectFormat" | i18n }}</bit-label>
<bit-select
id="type"
name="Format"
bitInput
[(ngModel)]="format"
[disabled]="importBlockedByPolicy"
required
>
<bit-label>{{ "fileFormat" | i18n }}</bit-label>
<bit-select formControlName="format">
<bit-option *ngFor="let o of featuredImportOptions" [value]="o.id" [label]="o.name" />
<ng-container *ngIf="importOptions && importOptions.length">
<bit-option value="-" disabled />
@ -22,7 +71,7 @@
</ng-container>
</bit-select>
</bit-form-field>
<app-callout type="info" title="{{ getFormatInstructionTitle() }}" *ngIf="format">
<bit-callout type="info" title="{{ getFormatInstructionTitle() }}" *ngIf="format">
<ng-container *ngIf="format === 'bitwardencsv' || format === 'bitwardenjson'">
See detailed instructions on our help site at
<a target="_blank" rel="noopener" href="https://bitwarden.com/help/export-your-data/">
@ -292,53 +341,48 @@
Log in to "https://vault.passky.org" &rarr; "Import & Export" &rarr; "Export" in the Passky
section. ("Backup" is unsupported as it is encrypted).
</ng-container>
</app-callout>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="file">2. {{ "selectImportFile" | i18n }}</label>
<br />
<div class="file-selector">
<button
type="button"
class="btn btn-outline-primary"
(click)="fileSelector.click()"
[disabled]="importBlockedByPolicy"
>
{{ "chooseFile" | i18n }}
</button>
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
</div>
<input
#fileSelector
type="file"
id="file"
class="form-control-file"
name="file"
(change)="setSelectedFile($event)"
hidden
[disabled]="importBlockedByPolicy"
/>
</div>
</bit-callout>
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
<div class="file-selector">
<button
bitButton
type="button"
class="btn btn-outline-primary"
(click)="fileSelector.click()"
>
{{ "chooseFile" | i18n }}
</button>
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
</div>
</div>
<div class="form-group">
<label for="fileContents">{{ "orCopyPasteFileContents" | i18n }}</label>
<input
bitInput
#fileSelector
type="file"
id="file"
class="form-control-file"
name="file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
<textarea
id="fileContents"
class="form-control"
bitInput
name="FileContents"
[(ngModel)]="fileContents"
[disabled]="importBlockedByPolicy"
formControlName="fileContents"
></textarea>
</div>
</bit-form-field>
<button
bitButton
bitFormButton
type="submit"
class="btn btn-primary btn-submit"
[disabled]="loading || importBlockedByPolicy"
[ngClass]="{ manual: importBlockedByPolicy }"
buttonType="primary"
[disabled]="importBlockedByPolicy"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "importData" | i18n }}</span>
{{ "importData" | i18n }}
</button>
</form>

View File

@ -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<FolderView[]>;
collections$: Observable<CollectionView[]>;
organizations$: Observable<Organization[]>;
protected organizationId: string = null;
protected destroy$ = new Subject<void>();
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<unknown, ImportResult>(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() {

View File

@ -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"
},

View File

@ -57,6 +57,12 @@ export function canAccessAdmin(i18nService: I18nService) {
);
}
export function canAccessImportExport(i18nService: I18nService) {
return map<Organization[], Organization[]>((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

View File

@ -9,7 +9,9 @@ export abstract class ImportServiceAbstraction {
import: (
importer: Importer,
fileContents: string,
organizationId?: string
organizationId?: string,
selectedImportTarget?: string,
isUserAdmin?: boolean
) => Promise<ImportResult>;
getImporter: (
format: ImportType | "bitwardenpasswordprotected",

View File

@ -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}`);
});
});
});

View File

@ -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<ImportResult> {
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]);
});
}
}