mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-29 04:17:41 +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 { Component } from "@angular/core";
|
||||||
|
import { FormBuilder } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { switchMap, takeUntil } from "rxjs/operators";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { ImportServiceAbstraction } from "@bitwarden/importer";
|
import { ImportServiceAbstraction } from "@bitwarden/importer";
|
||||||
|
|
||||||
@ -37,11 +40,14 @@ export class OrganizationImportComponent extends ImportComponent {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
platformUtilsService: PlatformUtilsService,
|
platformUtilsService: PlatformUtilsService,
|
||||||
policyService: PolicyService,
|
policyService: PolicyService,
|
||||||
private organizationService: OrganizationService,
|
organizationService: OrganizationService,
|
||||||
logService: LogService,
|
logService: LogService,
|
||||||
modalService: ModalService,
|
modalService: ModalService,
|
||||||
syncService: SyncService,
|
syncService: SyncService,
|
||||||
dialogService: DialogServiceAbstraction
|
dialogService: DialogServiceAbstraction,
|
||||||
|
folderService: FolderService,
|
||||||
|
collectionService: CollectionService,
|
||||||
|
formBuilder: FormBuilder
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
i18nService,
|
i18nService,
|
||||||
@ -52,7 +58,11 @@ export class OrganizationImportComponent extends ImportComponent {
|
|||||||
logService,
|
logService,
|
||||||
modalService,
|
modalService,
|
||||||
syncService,
|
syncService,
|
||||||
dialogService
|
dialogService,
|
||||||
|
folderService,
|
||||||
|
collectionService,
|
||||||
|
organizationService,
|
||||||
|
formBuilder
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,11 +84,10 @@ export class OrganizationImportComponent extends ImportComponent {
|
|||||||
await this.router.navigate(["organizations", this.organizationId, "vault"]);
|
await this.router.navigate(["organizations", this.organizationId, "vault"]);
|
||||||
} else {
|
} else {
|
||||||
this.fileSelected = null;
|
this.fileSelected = null;
|
||||||
this.fileContents = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit() {
|
protected async performImport() {
|
||||||
const confirmed = await this.dialogService.openSimpleDialog({
|
const confirmed = await this.dialogService.openSimpleDialog({
|
||||||
title: { key: "warning" },
|
title: { key: "warning" },
|
||||||
content: { key: "importWarning", placeholders: [this.organization.name] },
|
content: { key: "importWarning", placeholders: [this.organization.name] },
|
||||||
@ -88,6 +97,6 @@ export class OrganizationImportComponent extends ImportComponent {
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
super.submit();
|
await super.performImport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,69 @@
|
|||||||
<div class="page-header">
|
<h1 bitTypography="h1">{{ "importData" | i18n }}</h1>
|
||||||
<h1>{{ "importData" | i18n }}</h1>
|
|
||||||
</div>
|
<bit-callout type="info" *ngIf="importBlockedByPolicy">
|
||||||
<app-callout type="info" *ngIf="importBlockedByPolicy">
|
|
||||||
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
|
||||||
</app-callout>
|
</bit-callout>
|
||||||
<form #form (ngSubmit)="submit()" ngNativeValidate>
|
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||||
<bit-form-field class="tw-w-1/2">
|
<bit-form-field>
|
||||||
<bit-label for="type">1. {{ "selectFormat" | i18n }}</bit-label>
|
<bit-label
|
||||||
<bit-select
|
>{{ "importDestination" | i18n }}
|
||||||
id="type"
|
<a
|
||||||
name="Format"
|
target="_blank"
|
||||||
bitInput
|
rel="noopener"
|
||||||
[(ngModel)]="format"
|
appA11yTitle="{{ 'learnAboutImportOptions' | i18n }}"
|
||||||
[disabled]="importBlockedByPolicy"
|
href="https://bitwarden.com/help/import-data/"
|
||||||
required
|
|
||||||
>
|
>
|
||||||
|
<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>{{ "fileFormat" | i18n }}</bit-label>
|
||||||
|
<bit-select formControlName="format">
|
||||||
<bit-option *ngFor="let o of featuredImportOptions" [value]="o.id" [label]="o.name" />
|
<bit-option *ngFor="let o of featuredImportOptions" [value]="o.id" [label]="o.name" />
|
||||||
<ng-container *ngIf="importOptions && importOptions.length">
|
<ng-container *ngIf="importOptions && importOptions.length">
|
||||||
<bit-option value="-" disabled />
|
<bit-option value="-" disabled />
|
||||||
@ -22,7 +71,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</bit-select>
|
</bit-select>
|
||||||
</bit-form-field>
|
</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'">
|
<ng-container *ngIf="format === 'bitwardencsv' || format === 'bitwardenjson'">
|
||||||
See detailed instructions on our help site at
|
See detailed instructions on our help site at
|
||||||
<a target="_blank" rel="noopener" href="https://bitwarden.com/help/export-your-data/">
|
<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
|
Log in to "https://vault.passky.org" → "Import & Export" → "Export" in the Passky
|
||||||
section. ("Backup" is unsupported as it is encrypted).
|
section. ("Backup" is unsupported as it is encrypted).
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</app-callout>
|
</bit-callout>
|
||||||
<div class="row">
|
<bit-form-field>
|
||||||
<div class="col-6">
|
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
|
||||||
<div class="form-group">
|
|
||||||
<label for="file">2. {{ "selectImportFile" | i18n }}</label>
|
|
||||||
<br />
|
|
||||||
<div class="file-selector">
|
<div class="file-selector">
|
||||||
<button
|
<button
|
||||||
|
bitButton
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
(click)="fileSelector.click()"
|
(click)="fileSelector.click()"
|
||||||
[disabled]="importBlockedByPolicy"
|
|
||||||
>
|
>
|
||||||
{{ "chooseFile" | i18n }}
|
{{ "chooseFile" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
|
{{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
bitInput
|
||||||
#fileSelector
|
#fileSelector
|
||||||
type="file"
|
type="file"
|
||||||
id="file"
|
id="file"
|
||||||
class="form-control-file"
|
class="form-control-file"
|
||||||
name="file"
|
name="file"
|
||||||
|
formControlName="file"
|
||||||
(change)="setSelectedFile($event)"
|
(change)="setSelectedFile($event)"
|
||||||
hidden
|
hidden
|
||||||
[disabled]="importBlockedByPolicy"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</bit-form-field>
|
||||||
</div>
|
<bit-form-field>
|
||||||
</div>
|
<bit-label>{{ "orCopyPasteFileContents" | i18n }}</bit-label>
|
||||||
<div class="form-group">
|
|
||||||
<label for="fileContents">{{ "orCopyPasteFileContents" | i18n }}</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="fileContents"
|
id="fileContents"
|
||||||
class="form-control"
|
bitInput
|
||||||
name="FileContents"
|
name="FileContents"
|
||||||
[(ngModel)]="fileContents"
|
formControlName="fileContents"
|
||||||
[disabled]="importBlockedByPolicy"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</bit-form-field>
|
||||||
<button
|
<button
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary btn-submit"
|
buttonType="primary"
|
||||||
[disabled]="loading || importBlockedByPolicy"
|
[disabled]="importBlockedByPolicy"
|
||||||
[ngClass]="{ manual: importBlockedByPolicy }"
|
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
{{ "importData" | i18n }}
|
||||||
<span>{{ "importData" | i18n }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,18 +1,29 @@
|
|||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import { FormBuilder, Validators } from "@angular/forms";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import * as JSZip from "jszip";
|
import * as JSZip from "jszip";
|
||||||
import { Subject, lastValueFrom } from "rxjs";
|
import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs";
|
||||||
import { takeUntil } from "rxjs/operators";
|
import { map, takeUntil } from "rxjs/operators";
|
||||||
import Swal, { SweetAlertIcon } from "sweetalert2";
|
import Swal, { SweetAlertIcon } from "sweetalert2";
|
||||||
|
|
||||||
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
|
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 { 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 {
|
import {
|
||||||
ImportOption,
|
ImportOption,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
@ -30,15 +41,31 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
featuredImportOptions: ImportOption[];
|
featuredImportOptions: ImportOption[];
|
||||||
importOptions: ImportOption[];
|
importOptions: ImportOption[];
|
||||||
format: ImportType = null;
|
format: ImportType = null;
|
||||||
fileContents: string;
|
|
||||||
fileSelected: File;
|
fileSelected: File;
|
||||||
loading = false;
|
|
||||||
|
folders$: Observable<FolderView[]>;
|
||||||
|
collections$: Observable<CollectionView[]>;
|
||||||
|
organizations$: Observable<Organization[]>;
|
||||||
|
|
||||||
protected organizationId: string = null;
|
protected organizationId: string = null;
|
||||||
protected destroy$ = new Subject<void>();
|
protected destroy$ = new Subject<void>();
|
||||||
|
|
||||||
private _importBlockedByPolicy = false;
|
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(
|
constructor(
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected importService: ImportServiceAbstraction,
|
protected importService: ImportServiceAbstraction,
|
||||||
@ -48,7 +75,11 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
protected modalService: ModalService,
|
protected modalService: ModalService,
|
||||||
protected syncService: SyncService,
|
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 {
|
protected get importBlockedByPolicy(): boolean {
|
||||||
@ -65,15 +96,76 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.setImportOptions();
|
this.setImportOptions();
|
||||||
|
|
||||||
this.policyService
|
this.organizations$ = concat(
|
||||||
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
|
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$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe((policyAppliesToActiveUser) => {
|
.subscribe(([policyApplies, orgs]) => {
|
||||||
this._importBlockedByPolicy = policyAppliesToActiveUser;
|
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) {
|
if (this.importBlockedByPolicy) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
@ -83,8 +175,6 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
const promptForPassword_callback = async () => {
|
const promptForPassword_callback = async () => {
|
||||||
return await this.getFilePassword();
|
return await this.getFilePassword();
|
||||||
};
|
};
|
||||||
@ -94,32 +184,28 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
promptForPassword_callback,
|
promptForPassword_callback,
|
||||||
this.organizationId
|
this.organizationId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (importer === null) {
|
if (importer === null) {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
this.i18nService.t("selectFormat")
|
this.i18nService.t("selectFormat")
|
||||||
);
|
);
|
||||||
this.loading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileEl = document.getElementById("file") as HTMLInputElement;
|
const fileEl = document.getElementById("file") as HTMLInputElement;
|
||||||
const files = fileEl.files;
|
const files = fileEl.files;
|
||||||
if (
|
let fileContents = this.formGroup.controls.fileContents.value;
|
||||||
(files == null || files.length === 0) &&
|
if ((files == null || files.length === 0) && (fileContents == null || fileContents === "")) {
|
||||||
(this.fileContents == null || this.fileContents === "")
|
|
||||||
) {
|
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
this.i18nService.t("selectFile")
|
this.i18nService.t("selectFile")
|
||||||
);
|
);
|
||||||
this.loading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileContents = this.fileContents;
|
|
||||||
if (files != null && files.length > 0) {
|
if (files != null && files.length > 0) {
|
||||||
try {
|
try {
|
||||||
const content = await this.getFileContents(files[0]);
|
const content = await this.getFileContents(files[0]);
|
||||||
@ -137,12 +223,21 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
this.i18nService.t("errorOccurred"),
|
this.i18nService.t("errorOccurred"),
|
||||||
this.i18nService.t("selectFile")
|
this.i18nService.t("selectFile")
|
||||||
);
|
);
|
||||||
this.loading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.organizationId) {
|
||||||
|
await this.organizationService.get(this.organizationId)?.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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
|
//No errors, display success message
|
||||||
this.dialogService.open<unknown, ImportResult>(ImportSuccessDialogComponent, {
|
this.dialogService.open<unknown, ImportResult>(ImportSuccessDialogComponent, {
|
||||||
@ -155,8 +250,13 @@ export class ImportComponent implements OnInit, OnDestroy {
|
|||||||
this.error(e);
|
this.error(e);
|
||||||
this.logService.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() {
|
getFormatInstructionTitle() {
|
||||||
|
@ -1293,6 +1293,31 @@
|
|||||||
"importEncKeyError": {
|
"importEncKeyError": {
|
||||||
"message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data."
|
"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": {
|
"selectFormat": {
|
||||||
"message": "Select the format of the import file"
|
"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)
|
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
|
||||||
* @deprecated Use organizationService.memberOrganizations$ instead
|
* @deprecated Use organizationService.memberOrganizations$ instead
|
||||||
|
@ -9,7 +9,9 @@ export abstract class ImportServiceAbstraction {
|
|||||||
import: (
|
import: (
|
||||||
importer: Importer,
|
importer: Importer,
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
organizationId?: string
|
organizationId?: string,
|
||||||
|
selectedImportTarget?: string,
|
||||||
|
isUserAdmin?: boolean
|
||||||
) => Promise<ImportResult>;
|
) => Promise<ImportResult>;
|
||||||
getImporter: (
|
getImporter: (
|
||||||
format: ImportType | "bitwardenpasswordprotected",
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
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 { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer";
|
||||||
import { Importer } from "../importers/importer";
|
import { Importer } from "../importers/importer";
|
||||||
|
import { ImportResult } from "../models/import-result";
|
||||||
|
|
||||||
import { ImportApiServiceAbstraction } from "./import-api.service.abstraction";
|
import { ImportApiServiceAbstraction } from "./import-api.service.abstraction";
|
||||||
import { ImportService } from "./import.service";
|
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 { CollectionWithIdRequest } from "@bitwarden/common/vault/models/request/collection-with-id.request";
|
||||||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-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 { 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 {
|
import {
|
||||||
AscendoCsvImporter,
|
AscendoCsvImporter,
|
||||||
@ -106,7 +108,9 @@ export class ImportService implements ImportServiceAbstraction {
|
|||||||
async import(
|
async import(
|
||||||
importer: Importer,
|
importer: Importer,
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
organizationId: string = null
|
organizationId: string = null,
|
||||||
|
selectedImportTarget: string = null,
|
||||||
|
isUserAdmin: boolean
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
let importResult: ImportResult;
|
let importResult: ImportResult;
|
||||||
try {
|
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 {
|
try {
|
||||||
|
await this.setImportTarget(importResult, organizationId, selectedImportTarget);
|
||||||
if (organizationId != null) {
|
if (organizationId != null) {
|
||||||
await this.handleOrganizationalImport(importResult, organizationId);
|
await this.handleOrganizationalImport(importResult, organizationId);
|
||||||
} else {
|
} else {
|
||||||
@ -403,4 +417,69 @@ export class ImportService implements ImportServiceAbstraction {
|
|||||||
|
|
||||||
return new Error(errorMessage);
|
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