mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-28 04:08:47 +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:
parent
b89f31101f
commit
e98cbed437
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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" → "Import & Export" → "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>
|
||||
|
@ -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() {
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user