[PM-8254] Create shareable export component (#9246)

* Make export.component a standalone component

Fix lint issue with takeUntil

* Create shareable export.component.html

Copied existing export.component.html as that has already been migrated to use the component library components
Strip the markup from the dialog and the submit-button

* Add outputs to inform the hosting component about certain events (submit, loading, disabled)

Emit successful Export

Expose a form-id so the hosting component can bind to this form

Fix name of output

* Ensure that the file gets prefixed with `org`when exporting from an organization

* When exporting from an organization ensure Organization_ClientExportedVault is collected

* Add comments to the components outputs

* Better way of addressing the previously fixed lint issue

* Fix disabling the form not emitting the formDisabled state

* Add better comments to Outputs based on PR feedback

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Daniel James Smith 2024-05-28 22:32:01 +02:00 committed by GitHub
parent bc170f5207
commit c749447894
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 191 additions and 7 deletions

View File

@ -0,0 +1,100 @@
<bit-callout type="danger" title="{{ 'vaultExportDisabled' | i18n }}" *ngIf="disabledByPolicy">
{{ "personalVaultExportPolicyInEffect" | i18n }}
</bit-callout>
<tools-export-scope-callout
[organizationId]="organizationId"
*ngIf="!disabledByPolicy"
></tools-export-scope-callout>
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
<ng-container *ngIf="organizations$ | async as organizations">
<bit-form-field *ngIf="organizations.length > 0">
<bit-label>{{ "exportFrom" | i18n }}</bit-label>
<bit-select formControlName="vaultSelector">
<bit-option [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>
</ng-container>
<bit-form-field>
<bit-label>{{ "fileFormat" | i18n }}</bit-label>
<bit-select formControlName="format">
<bit-option *ngFor="let f of formatOptions" [value]="f.value" [label]="f.name" />
</bit-select>
</bit-form-field>
<ng-container *ngIf="format === 'encrypted_json'">
<bit-radio-group formControlName="fileEncryptionType" aria-label="exportTypeHeading">
<bit-label>{{ "exportTypeHeading" | i18n }}</bit-label>
<bit-radio-button
id="AccountEncrypted"
name="fileEncryptionType"
class="tw-block"
[value]="encryptedExportType.AccountEncrypted"
checked="fileEncryptionType === encryptedExportType.AccountEncrypted"
>
<bit-label>{{ "accountRestricted" | i18n }}</bit-label>
<bit-hint>{{ "accountRestrictedOptionDescription" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button
id="FileEncrypted"
name="fileEncryptionType"
class="tw-block"
[value]="encryptedExportType.FileEncrypted"
checked="fileEncryptionType === encryptedExportType.FileEncrypted"
>
<bit-label>{{ "passwordProtected" | i18n }}</bit-label>
<bit-hint>{{ "passwordProtectedOptionDescription" | i18n }}</bit-hint>
</bit-radio-button>
</bit-radio-group>
<ng-container *ngIf="fileEncryptionType == encryptedExportType.FileEncrypted">
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "filePassword" | i18n }}</bit-label>
<input
bitInput
type="password"
id="filePassword"
formControlName="filePassword"
name="password"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showFilePassword"
></button>
<bit-hint>{{ "exportPasswordDescription" | i18n }}</bit-hint>
</bit-form-field>
<app-password-strength [password]="filePassword" [showText]="true"> </app-password-strength>
</div>
<bit-form-field>
<bit-label>{{ "confirmFilePassword" | i18n }}</bit-label>
<input
bitInput
type="password"
id="confirmFilePassword"
formControlName="confirmFilePassword"
name="confirmFilePassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showFilePassword"
></button>
</bit-form-field>
</ng-container>
</ng-container>
</form>

View File

@ -1,7 +1,9 @@
import { Directive, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from "@angular/core";
import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms";
import { map, merge, Observable, startWith, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
@ -16,11 +18,70 @@ 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 { EncryptedExportType } from "@bitwarden/common/tools/enums/encrypted-export-type.enum";
import { DialogService } from "@bitwarden/components";
import {
AsyncActionsModule,
BitSubmitDirective,
ButtonModule,
CalloutModule,
DialogService,
FormFieldModule,
IconButtonModule,
RadioButtonModule,
SelectModule,
} from "@bitwarden/components";
import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core";
@Directive()
import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
@Component({
selector: "tools-export",
templateUrl: "export.component.html",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
JslibModule,
FormFieldModule,
AsyncActionsModule,
ButtonModule,
IconButtonModule,
SelectModule,
CalloutModule,
RadioButtonModule,
ExportScopeCalloutComponent,
UserVerificationDialogComponent,
],
})
export class ExportComponent implements OnInit, OnDestroy {
/**
* The hosting control also needs a bitSubmitDirective (on the Submit button) which calls this components {@link submit}-method.
* This components formState (loading/disabled) is emitted back up to the hosting component so for example the Submit button can be enabled/disabled and show loading state.
*/
@ViewChild(BitSubmitDirective)
private bitSubmit: BitSubmitDirective;
/**
* Emits true when the BitSubmitDirective({@link bitSubmit} is executing {@link submit} and false when execution has completed.
* Example: Used to show the loading state of the submit button present on the hosting component
* */
@Output()
formLoading = new EventEmitter<boolean>();
/**
* Emits true when this form gets disabled and false when enabled.
* Example: Used to disable the submit button, which is present on the hosting component
* */
@Output()
formDisabled = new EventEmitter<boolean>();
/**
* Emits when the creation and download of the export-file have succeeded
* - Emits an null/empty string when exporting from an individual vault
* - Emits the organizationId when exporting from an organizationl vault
* */
@Output()
onSuccessfulExport = new EventEmitter<string>();
@Output() onSaved = new EventEmitter();
@ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent;
@ -74,6 +135,11 @@ export class ExportComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
// Setup subscription to emit when this form is enabled/disabled
this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
this.formDisabled.emit(c === "DISABLED");
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
.pipe(takeUntil(this.destroy$))
@ -88,8 +154,7 @@ export class ExportComponent implements OnInit, OnDestroy {
this.exportForm.get("format").valueChanges,
this.exportForm.get("fileEncryptionType").valueChanges,
)
.pipe(takeUntil(this.destroy$))
.pipe(startWith(0))
.pipe(startWith(0), takeUntil(this.destroy$))
.subscribe(() => this.adjustValidators());
if (this.organizationId) {
@ -118,6 +183,12 @@ export class ExportComponent implements OnInit, OnDestroy {
this.exportForm.controls.vaultSelector.setValue("myVault");
}
ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
this.formLoading.emit(loading);
});
}
ngOnDestroy(): void {
this.destroy$.next();
}
@ -187,6 +258,7 @@ export class ExportComponent implements OnInit, OnDestroy {
protected saved() {
this.onSaved.emit();
this.onSuccessfulExport.emit(this.organizationId);
}
private async verifyUser(): Promise<boolean> {
@ -235,6 +307,10 @@ export class ExportComponent implements OnInit, OnDestroy {
}
protected getFileName(prefix?: string) {
if (this.organizationId) {
prefix = "org";
}
let extension = this.format;
if (this.format === "encrypted_json") {
if (prefix == null) {
@ -248,7 +324,15 @@ export class ExportComponent implements OnInit, OnDestroy {
}
protected async collectEvent(): Promise<void> {
await this.eventCollectionService.collect(EventType.User_ClientExportedVault);
if (this.organizationId) {
return await this.eventCollectionService.collect(
EventType.Organization_ClientExportedVault,
null,
false,
this.organizationId,
);
}
return await this.eventCollectionService.collect(EventType.User_ClientExportedVault);
}
get format() {