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:
committed by
GitHub
parent
d0e72f5554
commit
9e290a3fed
471
libs/importer/src/components/import.component.ts
Normal file
471
libs/importer/src/components/import.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user