1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 07:13:32 +00:00

[PM-4222] Make importer UI reusable (#6504)

* Split up import/export into separate modules

* Fix routing and apply PR feedback

* Renamed OrganizationExport exports to OrganizationVaultExport

* Make import dialogs standalone and move them to libs/importer

* Make import.component re-usable

- Move functionality which was previously present on the org-import.component into import.component
- Move import.component into libs/importer
Make import.component standalone
Create import-web.component to represent Web UI
Fix module imports and routing
Remove unused org-import-files

* Renamed filenames according to export rename

* Make ImportWebComponent standalone, simplify routing

* Pass organizationId as Input to ImportComponent

* use formLoading and formDisabled outputs

* Emit an event when the import succeeds

Remove Angular router from base-component as other clients might not have routing (i.e. desktop)
Move logic that happened on web successful import into the import-web.component

* fix table themes on desktop & browser

* fix fileSelector button styles

* update selectors to use tools prefix; remove unused selectors

* Wall off UI components in libs/importer

Create barrel-file for libs/importer/components
Remove components and dialog exports from libs/importer/index.ts
Extend libs/shared/tsconfig.libs.json to include @bitwarden/importer/ui -> libs/importer/components
Extend apps/web/tsconfig.ts to include @bitwarden/importer/ui
Update all usages

* Rename @bitwarden/importer to @bitwarden/importer/core

Create more barrel files in libs/importer/*
Update imports within libs/importer
Extend tsconfig files
Update imports in web, desktop, browser and cli

* Lazy-load the ImportWebComponent via both routes

* Use SharedModule as import in import-web.component

* File selector should be displayed as secondary

* Use bitSubmit to override submit preventDefault (#6607)

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: William Martin <contact@willmartian.com>
This commit is contained in:
Daniel James Smith
2023-10-19 11:17:23 +02:00
committed by GitHub
parent d0e72f5554
commit 9e290a3fed
42 changed files with 299 additions and 328 deletions

View File

@@ -0,0 +1,471 @@
import { CommonModule } from "@angular/common";
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import * as JSZip from "jszip";
import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs";
import { map, takeUntil } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
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 { 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 { 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 {
AsyncActionsModule,
BitSubmitDirective,
ButtonModule,
CalloutModule,
DialogService,
FormFieldModule,
IconButtonModule,
SelectModule,
} from "@bitwarden/components";
import { ImportOption, ImportResult, ImportType } from "../models";
import {
ImportApiService,
ImportApiServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "../services";
import {
FilePasswordPromptComponent,
ImportErrorDialogComponent,
ImportSuccessDialogComponent,
} from "./dialog";
@Component({
selector: "tools-import",
templateUrl: "import.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
FormFieldModule,
AsyncActionsModule,
ButtonModule,
IconButtonModule,
SelectModule,
CalloutModule,
ReactiveFormsModule,
],
providers: [
{
provide: ImportApiServiceAbstraction,
useClass: ImportApiService,
deps: [ApiService],
},
{
provide: ImportServiceAbstraction,
useClass: ImportService,
deps: [
CipherService,
FolderService,
ImportApiServiceAbstraction,
I18nService,
CollectionService,
CryptoService,
],
},
],
})
export class ImportComponent implements OnInit, OnDestroy {
featuredImportOptions: ImportOption[];
importOptions: ImportOption[];
format: ImportType = null;
fileSelected: File;
folders$: Observable<FolderView[]>;
collections$: Observable<CollectionView[]>;
organizations$: Observable<Organization[]>;
private _organizationId: string;
get organizationId(): string {
return this._organizationId;
}
@Input() set organizationId(value: string) {
this._organizationId = value;
this.organizationService
.get$(this._organizationId)
.pipe(takeUntil(this.destroy$))
.subscribe((organization) => {
this._organizationId = organization?.id;
this.organization = organization;
});
}
protected organization: Organization;
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: [],
});
@ViewChild(BitSubmitDirective)
private bitSubmit: BitSubmitDirective;
@Output()
formLoading = new EventEmitter<boolean>();
@Output()
formDisabled = new EventEmitter<boolean>();
@Output()
onSuccessfulImport = new EventEmitter<string>();
ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
this.formLoading.emit(loading);
});
this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
this.formDisabled.emit(disabled);
});
}
constructor(
protected i18nService: I18nService,
protected importService: ImportServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
private logService: LogService,
protected syncService: SyncService,
protected dialogService: DialogService,
protected folderService: FolderService,
protected collectionService: CollectionService,
protected organizationService: OrganizationService,
protected formBuilder: FormBuilder
) {}
protected get importBlockedByPolicy(): boolean {
return this._importBlockedByPolicy;
}
ngOnInit() {
this.setImportOptions();
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(([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;
});
}
submit = async () => {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
await this.performImport();
};
protected async performImport() {
if (this.organization) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "importWarning", placeholders: [this.organization.name] },
type: "warning",
});
if (!confirmed) {
return;
}
}
if (this.importBlockedByPolicy) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("personalOwnershipPolicyInEffectImports")
);
return;
}
const promptForPassword_callback = async () => {
return await this.getFilePassword();
};
const importer = this.importService.getImporter(
this.format,
promptForPassword_callback,
this.organizationId
);
if (importer === null) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectFormat")
);
return;
}
const fileEl = document.getElementById("file") as HTMLInputElement;
const files = fileEl.files;
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")
);
return;
}
if (files != null && files.length > 0) {
try {
const content = await this.getFileContents(files[0]);
if (content != null) {
fileContents = content;
}
} catch (e) {
this.logService.error(e);
}
}
if (fileContents == null || fileContents === "") {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectFile")
);
return;
}
if (this.organizationId) {
await this.organizationService.get(this.organizationId)?.isAdmin;
}
try {
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, {
data: result,
});
this.syncService.fullSync(true);
this.onSuccessfulImport.emit(this._organizationId);
} catch (e) {
this.dialogService.open<unknown, Error>(ImportErrorDialogComponent, {
data: e,
});
this.logService.error(e);
}
}
private isUserAdmin(organizationId?: string): boolean {
if (!organizationId) {
return false;
}
return this.organizationService.get(this.organizationId)?.isAdmin;
}
getFormatInstructionTitle() {
if (this.format == null) {
return null;
}
const results = this.featuredImportOptions
.concat(this.importOptions)
.filter((o) => o.id === this.format);
if (results.length > 0) {
return this.i18nService.t("instructionsFor", results[0].name);
}
return null;
}
protected setImportOptions() {
this.featuredImportOptions = [
{
id: null,
name: "-- " + this.i18nService.t("select") + " --",
},
...this.importService.featuredImportOptions,
];
this.importOptions = [...this.importService.regularImportOptions].sort((a, b) => {
if (a.name == null && b.name != null) {
return -1;
}
if (a.name != null && b.name == null) {
return 1;
}
if (a.name == null && b.name == null) {
return 0;
}
return this.i18nService.collator
? this.i18nService.collator.compare(a.name, b.name)
: a.name.localeCompare(b.name);
});
}
setSelectedFile(event: Event) {
const fileInputEl = <HTMLInputElement>event.target;
this.fileSelected = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
}
private getFileContents(file: File): Promise<string> {
if (this.format === "1password1pux" && file.name.endsWith(".1pux")) {
return this.extractZipContent(file, "export.data");
}
if (
this.format === "protonpass" &&
(file.type === "application/zip" ||
file.type == "application/x-zip-compressed" ||
file.name.endsWith(".zip"))
) {
return this.extractZipContent(file, "Proton Pass/data.json");
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(file, "utf-8");
reader.onload = (evt) => {
if (this.format === "lastpasscsv" && file.type === "text/html") {
const parser = new DOMParser();
const doc = parser.parseFromString((evt.target as any).result, "text/html");
const pre = doc.querySelector("pre");
if (pre != null) {
resolve(pre.textContent);
return;
}
reject();
return;
}
resolve((evt.target as any).result);
};
reader.onerror = () => {
reject();
};
});
}
private extractZipContent(zipFile: File, contentFilePath: string): Promise<string> {
return new JSZip()
.loadAsync(zipFile)
.then((zip) => {
return zip.file(contentFilePath).async("string");
})
.then(
function success(content) {
return content;
},
function error(e) {
return "";
}
);
}
async getFilePassword(): Promise<string> {
const dialog = this.dialogService.open<string>(FilePasswordPromptComponent, {
ariaModal: true,
});
return await lastValueFrom(dialog.closed);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}