1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00: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
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"
},