diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index ca1e8317a8..4b6524b7cc 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -15,7 +15,10 @@ "message": "No Folder" }, "importEncKeyError": { - "message": "Invalid file password." + "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + }, + "invalidFilePassword": { + "message": "Invalid file password, please use the password you entered when you created the export file." }, "importPasswordRequired": { "message": "File is password protected, please provide a decryption password." diff --git a/apps/web/src/app/components/user-verification-prompt.component.html b/apps/web/src/app/components/user-verification-prompt.component.html new file mode 100644 index 0000000000..37e2ce87cf --- /dev/null +++ b/apps/web/src/app/components/user-verification-prompt.component.html @@ -0,0 +1,26 @@ +<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="confirmUserTitle"> + <div class="modal-dialog modal-dialog-scrollable" role="document"> + <form class="modal-content" #form (ngSubmit)="submit()"> + <h2 class="tw-mt-6 tw-mb-6 tw-pl-3.5 tw-pr-3.5 tw-font-semibold" id="modalTitle | i18n "> + {{ modalTitle | i18n | uppercase }} + </h2> + <div class="tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-p-3.5"> + {{ confirmDescription | i18n }} + </div> + <div class="tw-p-3.5"> + <app-user-verification ngDefaultControl [formControl]="secret" name="secret"> + </app-user-verification> + </div> + <div + class="tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background-alt tw-p-3.5" + > + <button bitButton buttonType="primary" type="submit" appBlurClick> + <span>{{ confirmButtonText | i18n }}</span> + </button> + <button bitButton buttonType="secondary" data-dismiss="modal"> + {{ "cancel" | i18n }} + </button> + </div> + </form> + </div> +</div> diff --git a/apps/web/src/app/components/user-verification-prompt.component.ts b/apps/web/src/app/components/user-verification-prompt.component.ts new file mode 100644 index 0000000000..e057193aff --- /dev/null +++ b/apps/web/src/app/components/user-verification-prompt.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +import { UserVerificationPromptComponent as BaseUserVerificationPrompt } from "@bitwarden/angular/components/user-verification-prompt.component"; + +@Component({ + templateUrl: "user-verification-prompt.component.html", +}) +export class UserVerificationPromptComponent extends BaseUserVerificationPrompt {} diff --git a/apps/web/src/app/organizations/tools/import-export/org-export.component.ts b/apps/web/src/app/organizations/tools/import-export/org-export.component.ts index c2803cab9b..20b6690acd 100644 --- a/apps/web/src/app/organizations/tools/import-export/org-export.component.ts +++ b/apps/web/src/app/organizations/tools/import-export/org-export.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventService } from "@bitwarden/common/abstractions/event.service"; import { ExportService } from "@bitwarden/common/abstractions/export.service"; @@ -32,7 +33,8 @@ export class OrganizationExportComponent extends ExportComponent { logService: LogService, userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, - fileDownloadService: FileDownloadService + fileDownloadService: FileDownloadService, + modalService: ModalService ) { super( cryptoService, @@ -44,7 +46,8 @@ export class OrganizationExportComponent extends ExportComponent { logService, userVerificationService, formBuilder, - fileDownloadService + fileDownloadService, + modalService ); } diff --git a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts index 5d24a2cfd4..27d1c6cd39 100644 --- a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts +++ b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts @@ -1,6 +1,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { ImportService } from "@bitwarden/common/abstractions/import.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -26,9 +27,18 @@ export class OrganizationImportComponent extends ImportComponent { platformUtilsService: PlatformUtilsService, policyService: PolicyService, private organizationService: OrganizationService, - logService: LogService + logService: LogService, + modalService: ModalService ) { - super(i18nService, importService, router, platformUtilsService, policyService, logService); + super( + i18nService, + importService, + router, + platformUtilsService, + policyService, + logService, + modalService + ); } async ngOnInit() { diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index a9f6369a3d..59315f9253 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -24,6 +24,7 @@ import { NestedCheckboxComponent } from "../components/nested-checkbox.component import { OrganizationSwitcherComponent } from "../components/organization-switcher.component"; import { PasswordRepromptComponent } from "../components/password-reprompt.component"; import { PremiumBadgeComponent } from "../components/premium-badge.component"; +import { UserVerificationPromptComponent } from "../components/user-verification-prompt.component"; import { FooterComponent } from "../layouts/footer.component"; import { FrontendLayoutComponent } from "../layouts/frontend-layout.component"; import { NavbarComponent } from "../layouts/navbar.component"; @@ -253,6 +254,7 @@ import { SharedModule } from "."; PasswordGeneratorHistoryComponent, PasswordGeneratorPolicyComponent, PasswordRepromptComponent, + UserVerificationPromptComponent, PaymentComponent, PaymentMethodComponent, PersonalOwnershipPolicyComponent, diff --git a/apps/web/src/app/tools/import-export/export.component.html b/apps/web/src/app/tools/import-export/export.component.html index da667f3e44..4e27fc70f7 100644 --- a/apps/web/src/app/tools/import-export/export.component.html +++ b/apps/web/src/app/tools/import-export/export.component.html @@ -1,9 +1,9 @@ <form #form (ngSubmit)="submit()" - ngNativeValidate [appApiAction]="formPromise" [formGroup]="exportForm" + *ngIf="exportForm" > <div class="page-header"> <h1>{{ "exportVault" | i18n }}</h1> @@ -18,25 +18,150 @@ ></app-export-scope-callout> <div class="row"> - <div class="form-group col-6"> - <label for="format">{{ "fileFormat" | i18n }}</label> - <select class="form-control" id="format" name="Format" formControlName="format"> - <option *ngFor="let f of formatOptions" [value]="f.value">{{ f.name }}</option> - </select> + <div class="col-6"> + <bit-form-field> + <bit-label class="tw-text-lg" for="format">{{ "fileFormat" | i18n }}</bit-label> + <select bitInput name="format" formControlName="format"> + <option *ngFor="let f of formatOptions" [value]="f.value">{{ f.name }}</option> + </select> + </bit-form-field> </div> </div> <div class="row"> <div class="form-group col-6"> - <app-user-verification ngDefaultControl formControlName="secret" name="secret"> - </app-user-verification> + <ng-container *ngIf="format === 'encrypted_json'"> + <div role="radiogroup" aria-labelledby="fileTypeHeading"> + <label id="fileTypeHeading" class="tw-semi-bold tw-text-lg"> + {{ "fileTypeHeading" | i18n }} + </label> + + <div appBoxRow name="FileTypeOptions" class="tw-flex tw-items-center"> + <div> + <input + type="radio" + class="radio" + name="fileEncryptionType" + id="AccountEncrypted" + [value]="encryptedExportType.AccountEncrypted" + formControlName="fileEncryptionType" + [checked]="fileEncryptionType === encryptedExportType.AccountEncrypted" + /> + </div> + <div> + <label class="tw-semi-bold tw-text-md tw-ml-1 tw-mt-1 tw-mb-1" for="AccountEncrypted"> + {{ "accountBackup" | i18n }} + </label> + </div> + </div> + + <div class="tw-regular ml-3 pb-2 tw-text-sm"> + {{ "accountBackupOptionDescription" | i18n }} + </div> + + <div class="tw-flex tw-items-center"> + <div> + <input + type="radio" + class="radio" + name="fileEncryptionType" + id="FileEncrypted" + [value]="encryptedExportType.FileEncrypted" + formControlName="fileEncryptionType" + [checked]="fileEncryptionType === encryptedExportType.FileEncrypted" + /> + </div> + <div> + <label class="tw-semi-bold tw-text-md tw-ml-1 tw-mt-1 tw-mb-1" for="FileEncrypted">{{ + "passwordProtected" | i18n + }}</label> + </div> + </div> + + <div class="tw-regular ml-3 tw-text-sm"> + {{ "passwordProtectedOptionDescription" | i18n }} + </div> + </div> + <br /> + + <ng-container *ngIf="fileEncryptionType == encryptedExportType.FileEncrypted"> + <div class="input-group"> + <bit-form-field class="tw-w-full"> + <bit-label>{{ "filePassword" | i18n }}</bit-label> + <input + bitInput + type="{{ showFilePassword ? 'text' : 'password' }}" + id="filePassword" + formControlName="filePassword" + name="password" + /> + + <div class="input-group-append"> + <button + bitSuffix + bitButton + buttonType="secondary" + appStopClick + appA11yTitle="{{ 'toggleVisibility' | i18n }}" + [attr.aria-pressed]="showFilePassword" + (click)="toggleFilePassword()" + type="button" + > + <i + class="bwi bwi-lg" + aria-hidden="true" + [ngClass]="{ 'bwi-eye': !showFilePassword, 'bwi-eye-slash': showFilePassword }" + ></i> + </button> + </div> + </bit-form-field> + <div class="small text-muted"> + {{ "exportPasswordDescription" | i18n }} + </div> + </div> + <div class="input-group tw-mt-4"> + <bit-form-field class="tw-w-full"> + <bit-label>{{ "confirmFilePassword" | i18n }}</bit-label> + <input + bitInput + type="{{ showConfirmFilePassword ? 'text' : 'password' }}" + id="confirmFilePassword" + formControlName="confirmFilePassword" + name="confirmFilePassword" + /> + <div class="input-group-append"> + <button + bitSuffix + bitButton + buttonType="secondary" + appStopClick + appA11yTitle="{{ 'toggleVisibility' | i18n }}" + [attr.aria-pressed]="showConfirmFilePassword" + (click)="toggleConfirmFilePassword()" + type="button" + > + <i + class="bwi bwi-lg" + aria-hidden="true" + [ngClass]="{ + 'bwi-eye': !showConfirmFilePassword, + 'bwi-eye-slash': showConfirmFilePassword + }" + ></i> + </button> + </div> + </bit-form-field> + </div> + </ng-container> + </ng-container> + + <button + type="submit" + class="btn btn-primary btn-submit" + [disabled]="form.loading || disabled" + > + <i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> + <span>{{ "confirmFormat" | i18n }}</span> + </button> </div> </div> - <button - type="submit" - class="btn btn-primary btn-submit" - [disabled]="form.loading || exportForm.disabled" - > - <i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> - <span>{{ "exportVault" | i18n }}</span> - </button> </form> diff --git a/apps/web/src/app/tools/import-export/export.component.ts b/apps/web/src/app/tools/import-export/export.component.ts index b9d8d963b6..1b5738885b 100644 --- a/apps/web/src/app/tools/import-export/export.component.ts +++ b/apps/web/src/app/tools/import-export/export.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { ExportComponent as BaseExportComponent } from "@bitwarden/angular/components/export.component"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventService } from "@bitwarden/common/abstractions/event.service"; import { ExportService } from "@bitwarden/common/abstractions/export.service"; @@ -11,6 +12,9 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; +import { EncryptedExportType } from "@bitwarden/common/enums/encryptedExportType"; + +import { UserVerificationPromptComponent } from "src/app/components/user-verification-prompt.component"; @Component({ selector: "app-export", @@ -18,6 +22,7 @@ import { UserVerificationService } from "@bitwarden/common/abstractions/userVeri }) export class ExportComponent extends BaseExportComponent { organizationId: string; + encryptedExportType = EncryptedExportType; constructor( cryptoService: CryptoService, @@ -29,7 +34,8 @@ export class ExportComponent extends BaseExportComponent { logService: LogService, userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, - fileDownloadService: FileDownloadService + fileDownloadService: FileDownloadService, + private modalService: ModalService ) { super( cryptoService, @@ -46,8 +52,78 @@ export class ExportComponent extends BaseExportComponent { ); } + async submit() { + if (this.isFileEncryptedExport && this.filePassword != this.confirmFilePassword) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("filePasswordAndConfirmFilePasswordDoNotMatch") + ); + return; + } + + this.exportForm.markAllAsTouched(); + if (!this.exportForm.valid) { + return; + } + + if (this.disabledByPolicy) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("personalVaultExportPolicyInEffect") + ); + return; + } + + const userVerified = await this.verifyUser(); + if (!userVerified) { + return; + } + + this.doExport(); + } + protected saved() { super.saved(); this.platformUtilsService.showToast("success", null, this.i18nService.t("exportSuccess")); } + + private verifyUser() { + let confirmDescription = "exportWarningDesc"; + if (this.isFileEncryptedExport) { + confirmDescription = "fileEncryptedExportWarningDesc"; + } else if (this.isAccountEncryptedExport) { + confirmDescription = "encExportKeyWarningDesc"; + } + + const ref = this.modalService.open(UserVerificationPromptComponent, { + allowMultipleModals: true, + data: { + confirmDescription: confirmDescription, + confirmButtonText: "exportVault", + modalTitle: "confirmVaultExport", + }, + }); + + if (ref == null) { + return; + } + + return ref.onClosedPromise(); + } + + get isFileEncryptedExport() { + return ( + this.format === "encrypted_json" && + this.fileEncryptionType === EncryptedExportType.FileEncrypted + ); + } + + get isAccountEncryptedExport() { + return ( + this.format === "encrypted_json" && + this.fileEncryptionType === EncryptedExportType.AccountEncrypted + ); + } } diff --git a/apps/web/src/app/tools/import-export/file-password-prompt.component.html b/apps/web/src/app/tools/import-export/file-password-prompt.component.html new file mode 100644 index 0000000000..0d9e01e8de --- /dev/null +++ b/apps/web/src/app/tools/import-export/file-password-prompt.component.html @@ -0,0 +1,58 @@ +<div + class="modal fade" + role="dialog" + aria-modal="true" + [attr.aria-labelledby]="'confirmVaultImport' | i18n" +> + <div class="modal-dialog modal-dialog-scrollable" role="document"> + <form #form (ngSubmit)="submit()"> + <div class="form-group modal-content"> + <h2 class="tw-mt-6 tw-mb-6 tw-ml-3.5 tw-font-semibold" id="confirmVaultImport"> + {{ "confirmVaultImport" | i18n | uppercase }} + </h2> + <div + class="tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-pr-3.5 tw-pt-3.5 tw-pl-3.5" + > + {{ "confirmVaultImportDesc" | i18n }} + <bit-form-field class="tw-w-full tw-pt-3.5"> + <bit-label>{{ "confirmFilePassword" | i18n }}</bit-label> + <input + bitInput + required + type="{{ showFilePassword ? 'text' : 'password' }}" + name="filePassword" + [formControl]="filePassword" + appAutofocus + appInputVerbatim + /> + <button + bitSuffix + bitButton + appStopClick + appA11yTitle="{{ 'toggleVisibility' | i18n }}" + [attr.aria-pressed]="showFilePassword" + (click)="toggleFilePassword()" + type="button" + > + <i + class="bwi bwi-lg" + aria-hidden="true" + [ngClass]="{ 'bwi-eye': !showFilePassword, 'bwi-eye-slash': showFilePassword }" + ></i> + </button> + </bit-form-field> + </div> + <div + class="tw-flex tw-w-full tw-flex-wrap tw-items-center tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background-alt tw-pl-3.5 tw-pr-3.5 tw-pb-3.5 tw-pt-4 tw-pl-4 tw-pr-4" + > + <button bitButton buttonType="primary" class="tw-mr-2" type="submit" appBlurClick> + <span>{{ "importData" | i18n }}</span> + </button> + <button bitButton buttonType="secondary" type="button" (click)="cancel()"> + <span>{{ "cancel" | i18n }}</span> + </button> + </div> + </div> + </form> + </div> +</div> diff --git a/apps/web/src/app/tools/import-export/file-password-prompt.component.ts b/apps/web/src/app/tools/import-export/file-password-prompt.component.ts new file mode 100644 index 0000000000..5e6368728b --- /dev/null +++ b/apps/web/src/app/tools/import-export/file-password-prompt.component.ts @@ -0,0 +1,31 @@ +import { Component } from "@angular/core"; +import { FormControl, Validators } from "@angular/forms"; + +import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; + +@Component({ + templateUrl: "file-password-prompt.component.html", +}) +export class FilePasswordPromptComponent { + showFilePassword: boolean; + filePassword = new FormControl("", Validators.required); + + constructor(private modalRef: ModalRef) {} + + toggleFilePassword() { + this.showFilePassword = !this.showFilePassword; + } + + submit() { + this.filePassword.markAsTouched(); + if (!this.filePassword.valid) { + return; + } + + this.modalRef.close(this.filePassword.value); + } + + cancel() { + this.modalRef.close(null); + } +} diff --git a/apps/web/src/app/tools/import-export/import-export.module.ts b/apps/web/src/app/tools/import-export/import-export.module.ts index 90a09f2aa0..25eab99cd3 100644 --- a/apps/web/src/app/tools/import-export/import-export.module.ts +++ b/apps/web/src/app/tools/import-export/import-export.module.ts @@ -12,12 +12,13 @@ import { ImportService } from "@bitwarden/common/services/import.service"; import { LooseComponentsModule, SharedModule } from "../../shared"; import { ExportComponent } from "./export.component"; +import { FilePasswordPromptComponent } from "./file-password-prompt.component"; import { ImportExportRoutingModule } from "./import-export-routing.module"; import { ImportComponent } from "./import.component"; @NgModule({ imports: [SharedModule, LooseComponentsModule, ImportExportRoutingModule], - declarations: [ImportComponent, ExportComponent], + declarations: [ImportComponent, ExportComponent, FilePasswordPromptComponent], providers: [ { provide: ImportServiceAbstraction, diff --git a/apps/web/src/app/tools/import-export/import.component.ts b/apps/web/src/app/tools/import-export/import.component.ts index 86ef671f21..d74fe5b1fe 100644 --- a/apps/web/src/app/tools/import-export/import.component.ts +++ b/apps/web/src/app/tools/import-export/import.component.ts @@ -3,6 +3,7 @@ import { Router } from "@angular/router"; import * as JSZip from "jszip"; import Swal, { SweetAlertIcon } from "sweetalert2"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { ImportService } from "@bitwarden/common/abstractions/import.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -10,6 +11,9 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { ImportOption, ImportType } from "@bitwarden/common/enums/importOptions"; import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { ImportError } from "@bitwarden/common/importers/importError"; + +import { FilePasswordPromptComponent } from "./file-password-prompt.component"; @Component({ selector: "app-import", @@ -20,7 +24,7 @@ export class ImportComponent implements OnInit { importOptions: ImportOption[]; format: ImportType = null; fileContents: string; - formPromise: Promise<Error>; + formPromise: Promise<ImportError>; loading = false; importBlockedByPolicy = false; @@ -33,7 +37,8 @@ export class ImportComponent implements OnInit { protected router: Router, protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, - private logService: LogService + private logService: LogService, + protected modalService: ModalService ) {} async ngOnInit() { @@ -106,12 +111,25 @@ export class ImportComponent implements OnInit { try { this.formPromise = this.importService.import(importer, fileContents, this.organizationId); - const error = await this.formPromise; + let error = await this.formPromise; + + if (error?.passwordRequired) { + const filePassword = await this.getFilePassword(); + if (filePassword == null) { + this.loading = false; + return; + } + + error = await this.doPasswordProtectedImport(filePassword, fileContents); + } + if (error != null) { this.error(error); this.loading = false; return; } + + //No errors, display success message this.platformUtilsService.showToast("success", null, this.i18nService.t("importSuccess")); this.router.navigate(this.successNavigate); } catch (e) { @@ -225,4 +243,29 @@ export class ImportComponent implements OnInit { } ); } + + async getFilePassword(): Promise<string> { + const ref = this.modalService.open(FilePasswordPromptComponent, { + allowMultipleModals: true, + }); + + if (ref == null) { + return null; + } + + return await ref.onClosedPromise(); + } + + async doPasswordProtectedImport( + filePassword: string, + fileContents: string + ): Promise<ImportError> { + const passwordProtectedImporter = this.importService.getImporter( + "bitwardenpasswordprotected", + this.organizationId, + filePassword + ); + + return this.importService.import(passwordProtectedImporter, fileContents, this.organizationId); + } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0e71175277..845f1774ca 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -678,6 +678,9 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidFilePassword": { + "message": "Invalid file password, please use the password you entered when you created the export file." + }, "lockNow": { "message": "Lock Now" }, @@ -890,6 +893,48 @@ "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "confirmMasterPassword": { + "message": "Confirm Master Password" + }, + "confirmFormat": { + "message": "Confirm Format" + }, + "filePassword": { + "message": "File Password" + }, + "confirmFilePassword": { + "message": "Confirm File Password" + }, + "accountBackupOptionDescription": { + "message": "Leverages your Bitwarden account encryption, not master password, to protect the export. This export can only be imported into the current account. Use this to create a backup that cannot be used elsewhere." + }, + "passwordProtectedOptionDescription": { + "message": "Create a user-generated password to protect the export. Use this to create an export that can be used in other accounts." + }, + "fileTypeHeading": { + "message": "File Type" + }, + "accountBackup": { + "message": "Account Backup" + }, + "passwordProtected": { + "message": "Password Protected" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm File Password“ do not match." + }, + "confirmVaultImport": { + "message": "Confirm Vault Import" + }, + "confirmVaultImportDesc": { + "message": "This file is password-protected. Please enter the file password to import data." + }, "exportSuccess": { "message": "Your vault data has been exported." }, diff --git a/apps/web/src/scss/modals.scss b/apps/web/src/scss/modals.scss index 7d4fd478e5..f28dab845d 100644 --- a/apps/web/src/scss/modals.scss +++ b/apps/web/src/scss/modals.scss @@ -6,6 +6,21 @@ } } +.modal-footer-content { + border: none; + border-radius: none; + @include themify($themes) { + background-color: themed("footerBackgroundColor"); + } + position: relative; + display: flex; + flex-direction: column; + width: 100%; + pointer-events: auto; + background-clip: padding-box; + outline: 0; +} + .modal-dialog { border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 0.3rem; diff --git a/libs/angular/src/components/export.component.ts b/libs/angular/src/components/export.component.ts index d053b886d3..352ee1eed1 100644 --- a/libs/angular/src/components/export.component.ts +++ b/libs/angular/src/components/export.component.ts @@ -1,5 +1,6 @@ -import { Directive, EventEmitter, OnInit, Output } from "@angular/core"; -import { UntypedFormBuilder } from "@angular/forms"; +import { Directive, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; +import { UntypedFormBuilder, Validators } from "@angular/forms"; +import { merge, takeUntil, Subject, startWith } from "rxjs"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventService } from "@bitwarden/common/abstractions/event.service"; @@ -10,19 +11,25 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; +import { EncryptedExportType } from "@bitwarden/common/enums/encryptedExportType"; import { EventType } from "@bitwarden/common/enums/eventType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; @Directive() -export class ExportComponent implements OnInit { +export class ExportComponent implements OnInit, OnDestroy { @Output() onSaved = new EventEmitter(); formPromise: Promise<string>; disabledByPolicy = false; + showFilePassword: boolean; + showConfirmFilePassword: boolean; exportForm = this.formBuilder.group({ format: ["json"], secret: [""], + filePassword: ["", Validators.required], + confirmFilePassword: ["", Validators.required], + fileEncryptionType: [EncryptedExportType.AccountEncrypted], }); formatOptions = [ @@ -31,6 +38,8 @@ export class ExportComponent implements OnInit { { name: ".json (Encrypted)", value: "encrypted_json" }, ]; + private destroy$ = new Subject<void>(); + constructor( protected cryptoService: CryptoService, protected i18nService: I18nService, @@ -47,6 +56,18 @@ export class ExportComponent implements OnInit { async ngOnInit() { await this.checkExportDisabled(); + + merge( + this.exportForm.get("format").valueChanges, + this.exportForm.get("fileEncryptionType").valueChanges + ) + .pipe(takeUntil(this.destroy$)) + .pipe(startWith(0)) + .subscribe(() => this.adjustValidators()); + } + + ngOnDestroy(): void { + this.destroy$.next(); } async checkExportDisabled() { @@ -62,6 +83,20 @@ export class ExportComponent implements OnInit { return this.format === "encrypted_json"; } + protected async doExport() { + try { + this.formPromise = this.getExportData(); + const data = await this.formPromise; + this.downloadFile(data); + this.saved(); + await this.collectEvent(); + this.exportForm.get("secret").setValue(""); + this.exportForm.clearValidators(); + } catch (e) { + this.logService.error(e); + } + } + async submit() { if (this.disabledByPolicy) { this.platformUtilsService.showToast( @@ -76,25 +111,15 @@ export class ExportComponent implements OnInit { if (!acceptedWarning) { return; } - const secret = this.exportForm.get("secret").value; + try { await this.userVerificationService.verifyUser(secret); } catch (e) { this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message); - return; } - try { - this.formPromise = this.getExportData(); - const data = await this.formPromise; - this.downloadFile(data); - this.saved(); - await this.collectEvent(); - this.exportForm.get("secret").setValue(""); - } catch (e) { - this.logService.error(e); - } + this.doExport(); } async warningDialog() { @@ -126,7 +151,14 @@ export class ExportComponent implements OnInit { } protected getExportData() { - return this.exportService.getExport(this.format); + if ( + this.format === "encrypted_json" && + this.fileEncryptionType === EncryptedExportType.FileEncrypted + ) { + return this.exportService.getPasswordProtectedExport(this.filePassword); + } else { + return this.exportService.getExport(this.format, null); + } } protected getFileName(prefix?: string) { @@ -150,6 +182,41 @@ export class ExportComponent implements OnInit { return this.exportForm.get("format").value; } + get filePassword() { + return this.exportForm.get("filePassword").value; + } + + get confirmFilePassword() { + return this.exportForm.get("confirmFilePassword").value; + } + + get fileEncryptionType() { + return this.exportForm.get("fileEncryptionType").value; + } + + toggleFilePassword() { + this.showFilePassword = !this.showFilePassword; + document.getElementById("filePassword").focus(); + } + + toggleConfirmFilePassword() { + this.showConfirmFilePassword = !this.showConfirmFilePassword; + document.getElementById("confirmFilePassword").focus(); + } + + adjustValidators() { + this.exportForm.get("confirmFilePassword").reset(); + this.exportForm.get("filePassword").reset(); + + if (this.encryptedFormat && this.fileEncryptionType == EncryptedExportType.FileEncrypted) { + this.exportForm.controls.filePassword.enable(); + this.exportForm.controls.confirmFilePassword.enable(); + } else { + this.exportForm.controls.filePassword.disable(); + this.exportForm.controls.confirmFilePassword.disable(); + } + } + private downloadFile(csv: string): void { const fileName = this.getFileName(); this.fileDownloadService.download({ diff --git a/libs/angular/src/components/user-verification-prompt.component.ts b/libs/angular/src/components/user-verification-prompt.component.ts new file mode 100644 index 0000000000..5c2a4c620f --- /dev/null +++ b/libs/angular/src/components/user-verification-prompt.component.ts @@ -0,0 +1,46 @@ +import { Directive } from "@angular/core"; +import { FormBuilder, FormControl } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; + +import { ModalConfig } from "../services/modal.service"; + +import { ModalRef } from "./modal/modal.ref"; + +/** + * Used to verify the user's identity (using their master password or email-based OTP for Key Connector users). You can customize all of the text in the modal. + */ +@Directive() +export class UserVerificationPromptComponent { + confirmDescription = this.config.data.confirmDescription; + confirmButtonText = this.config.data.confirmButtonText; + modalTitle = this.config.data.modalTitle; + secret = new FormControl(); + + constructor( + private modalRef: ModalRef, + protected config: ModalConfig, + protected userVerificationService: UserVerificationService, + private formBuilder: FormBuilder, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService + ) {} + + async submit() { + try { + //Incorrect secret will throw an invalid password error. + await this.userVerificationService.verifyUser(this.secret.value); + } catch (e) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("error"), + this.i18nService.t("invalidMasterPassword") + ); + return; + } + + this.modalRef.close(true); + } +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index dda6a84a7f..a1625ab754 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -390,6 +390,7 @@ export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES"); CipherServiceAbstraction, ApiServiceAbstraction, CryptoServiceAbstraction, + CryptoFunctionServiceAbstraction, ], }, { diff --git a/libs/common/src/enums/encryptedExportType.ts b/libs/common/src/enums/encryptedExportType.ts new file mode 100644 index 0000000000..4767869f7d --- /dev/null +++ b/libs/common/src/enums/encryptedExportType.ts @@ -0,0 +1,4 @@ +export enum EncryptedExportType { + AccountEncrypted = 0, + FileEncrypted = 1, +} diff --git a/libs/common/src/importers/bitwardenPasswordProtectedImporter.ts b/libs/common/src/importers/bitwardenPasswordProtectedImporter.ts index 54da71b5e5..c13d6f3a4f 100644 --- a/libs/common/src/importers/bitwardenPasswordProtectedImporter.ts +++ b/libs/common/src/importers/bitwardenPasswordProtectedImporter.ts @@ -35,7 +35,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im if (!(await this.checkPassword(parsedData))) { result.success = false; - result.errorMessage = this.i18nService.t("importEncKeyError"); + result.errorMessage = this.i18nService.t("invalidFilePassword"); return result; }