[PM- 9666] Implement edit item view individual vault (#10553)
* Add initial vault cipher form for cipher edit. * Add ability to add new cipher by type * Add ability to save and clone cipher, * Update canEditAllCiphers to take 1 argument. * Add attachments button to add/edit dialog. * Add semi-working attachment dialog. * Add working attachment functionality. * Remove debugging code. * Add tests for new attachments dialog component. * Add AddEditComponentV2 tests. * Remove AddEditComponentV2 delete functionality. * Remove unnecessary else statement. * Launch password generation in new dialog when extension refresh enabled. * Add tests for PasswordGeneratorComponent. * Adjust password and attachments dialog sizes. * run lint:fix * Remove unnecessary form from button. * Add missing provider in test. * Remove password generation events. * Add WebVaultGeneratorDialogComponent and WebCipherFormGenerationService * Move and rename CipherFormQueryParams * Use WebCipherFormGenerationService to launch password / user generation modals. * Add WebVaultGeneratorDialogComponent tests. * Remove unnecessary functionality and corresponding tests. * Fix failing tests. * Remove unused properties from AddEditComponentV2 * Pass CipherFormConfig to dialog. * Clean up unused attachment dialog functionality. * Update AddEdit cancel functionality to prevent navigating user. * Make attachment dialog open a static method. * Add addCipherV2 method and clean up tests. * Remove changes to QueryParams. * Add tests for WebCipherFormGenerationService * Remove unused onCipherSaved method. * Remove cipherSaved event. * Remove unused password generator component * Refactor to simplify editCipherId for extensionRefresh flag. * Add additional comments to AddEditComponentV2. * Simplify open vault generator dialog comment. * Remove unused organizationService * Remove unnecessary typecasting. * Remove extensionRefreshEnabled and related. * Remove slideIn animation * Remove unused AddEditComponentV2 properties. * Add back generic typing. * Condesnse properties into single form config. * Remove onDestroy and related code. * Run prettier * fix injection warning * Handle cipher save. * Redirect to vault on delete and make actions consistent. * Update comment.
This commit is contained in:
parent
a674f698a2
commit
931f86c948
|
@ -0,0 +1,34 @@
|
|||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
{{ headerText }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<vault-cipher-form
|
||||
*ngIf="!loading"
|
||||
formId="cipherForm"
|
||||
[config]="config"
|
||||
[submitBtn]="submitBtn"
|
||||
(cipherSaved)="onCipherSaved($event)"
|
||||
>
|
||||
<bit-item slot="attachment-button">
|
||||
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
|
||||
<p class="tw-m-0">
|
||||
{{ "attachments" | i18n }}
|
||||
<span *ngIf="!canAccessAttachments" bitBadge variant="success" class="tw-ml-2">
|
||||
{{ "premium" | i18n }}
|
||||
</span>
|
||||
</p>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
</vault-cipher-form>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
|
@ -0,0 +1,124 @@
|
|||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
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 { DialogService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
import { CipherFormConfig, DefaultCipherFormConfigService } from "@bitwarden/vault";
|
||||
|
||||
import { AddEditComponentV2 } from "./add-edit-v2.component";
|
||||
|
||||
describe("AddEditComponentV2", () => {
|
||||
let component: AddEditComponentV2;
|
||||
let fixture: ComponentFixture<AddEditComponentV2>;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let activatedRoute: MockProxy<ActivatedRoute>;
|
||||
let dialogRef: MockProxy<DialogRef<any>>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let folderService: MockProxy<FolderService>;
|
||||
let collectionService: MockProxy<CollectionService>;
|
||||
|
||||
const mockParams = {
|
||||
cloneMode: false,
|
||||
cipherFormConfig: mock<CipherFormConfig>(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockOrganization: Organization = {
|
||||
id: "org-id",
|
||||
name: "Test Organization",
|
||||
} as Organization;
|
||||
|
||||
organizationService = mock<OrganizationService>();
|
||||
organizationService.organizations$ = of([mockOrganization]);
|
||||
|
||||
policyService = mock<PolicyService>();
|
||||
policyService.policyAppliesToActiveUser$.mockImplementation((policyType: PolicyType) =>
|
||||
of(true),
|
||||
);
|
||||
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true);
|
||||
|
||||
activatedRoute = mock<ActivatedRoute>();
|
||||
activatedRoute.queryParams = of({});
|
||||
|
||||
dialogRef = mock<DialogRef<any>>();
|
||||
dialogService = mock<DialogService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
folderService = mock<FolderService>();
|
||||
folderService.folderViews$ = of([]);
|
||||
collectionService = mock<CollectionService>();
|
||||
collectionService.decryptedCollections$ = of([]);
|
||||
|
||||
const mockDefaultCipherFormConfigService = {
|
||||
buildConfig: jest.fn().mockResolvedValue({
|
||||
allowPersonal: true,
|
||||
allowOrganization: true,
|
||||
}),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddEditComponentV2],
|
||||
providers: [
|
||||
{ provide: DIALOG_DATA, useValue: mockParams },
|
||||
{ provide: DialogRef, useValue: dialogRef },
|
||||
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
{ provide: CipherService, useValue: cipherService },
|
||||
{ provide: MessagingService, useValue: messagingService },
|
||||
{ provide: OrganizationService, useValue: organizationService },
|
||||
{ provide: Router, useValue: mock<Router>() },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: CollectionService, useValue: collectionService },
|
||||
{ provide: FolderService, useValue: folderService },
|
||||
{ provide: CryptoService, useValue: mock<CryptoService>() },
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: DefaultCipherFormConfigService, useValue: mockDefaultCipherFormConfigService },
|
||||
{
|
||||
provide: PasswordGenerationServiceAbstraction,
|
||||
useValue: mock<PasswordGenerationServiceAbstraction>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AddEditComponentV2);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("initializes the component with cipher", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("handles cancel action", async () => {
|
||||
const spyClose = jest.spyOn(dialogRef, "close");
|
||||
|
||||
await component.cancel();
|
||||
|
||||
expect(spyClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,177 @@
|
|||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { AsyncActionsModule, DialogModule, DialogService, ItemModule } from "@bitwarden/components";
|
||||
import {
|
||||
CipherAttachmentsComponent,
|
||||
CipherFormConfig,
|
||||
CipherFormGenerationService,
|
||||
CipherFormMode,
|
||||
CipherFormModule,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service";
|
||||
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
|
||||
import { AttachmentsV2Component } from "./attachments-v2.component";
|
||||
|
||||
/**
|
||||
* The result of the AddEditCipherDialogV2 component.
|
||||
*/
|
||||
export enum AddEditCipherDialogResult {
|
||||
Edited = "edited",
|
||||
Added = "added",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
/**
|
||||
* The close result of the AddEditCipherDialogV2 component.
|
||||
*/
|
||||
export interface AddEditCipherDialogCloseResult {
|
||||
/**
|
||||
* The action that was taken.
|
||||
*/
|
||||
action: AddEditCipherDialogResult;
|
||||
/**
|
||||
* The ID of the cipher that was edited or added.
|
||||
*/
|
||||
id?: CipherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for viewing a cipher, presented in a dialog.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-vault-add-edit-v2",
|
||||
templateUrl: "add-edit-v2.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CipherViewComponent,
|
||||
CommonModule,
|
||||
AsyncActionsModule,
|
||||
DialogModule,
|
||||
SharedModule,
|
||||
CipherFormModule,
|
||||
CipherAttachmentsComponent,
|
||||
ItemModule,
|
||||
],
|
||||
providers: [{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }],
|
||||
})
|
||||
export class AddEditComponentV2 implements OnInit {
|
||||
config: CipherFormConfig;
|
||||
headerText: string;
|
||||
canAccessAttachments: boolean = false;
|
||||
|
||||
/**
|
||||
* Constructor for the AddEditComponentV2 component.
|
||||
* @param params The parameters for the component.
|
||||
* @param dialogRef The reference to the dialog.
|
||||
* @param i18nService The internationalization service.
|
||||
* @param dialogService The dialog service.
|
||||
* @param billingAccountProfileStateService The billing account profile state service.
|
||||
*/
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) public params: CipherFormConfig,
|
||||
private dialogRef: DialogRef<AddEditCipherDialogCloseResult>,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((canAccessPremium) => {
|
||||
this.canAccessAttachments = canAccessPremium;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook for component initialization.
|
||||
*/
|
||||
async ngOnInit() {
|
||||
this.config = this.params;
|
||||
this.headerText = this.setHeader(this.config?.mode, this.config.cipherType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter to check if the component is loading.
|
||||
*/
|
||||
get loading() {
|
||||
return this.config == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle cancel action. Called when a user clicks the cancel button.
|
||||
*/
|
||||
async cancel() {
|
||||
this.dialogRef.close({ action: AddEditCipherDialogResult.Canceled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the header text based on the mode and type of the cipher.
|
||||
* @param mode The form mode.
|
||||
* @param type The cipher type.
|
||||
* @returns The header text.
|
||||
*/
|
||||
setHeader(mode: CipherFormMode, type: CipherType) {
|
||||
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
|
||||
switch (type) {
|
||||
case CipherType.Login:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
|
||||
case CipherType.Card:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
|
||||
case CipherType.Identity:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
|
||||
case CipherType.SecureNote:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the attachments dialog.
|
||||
*/
|
||||
async openAttachmentsDialog() {
|
||||
this.dialogService.open<AttachmentsV2Component, { cipherId: CipherId }>(
|
||||
AttachmentsV2Component,
|
||||
{
|
||||
data: {
|
||||
cipherId: this.config.originalCipher?.id as CipherId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the event when a cipher is saved.
|
||||
* @param cipherView The cipher view that was saved.
|
||||
*/
|
||||
async onCipherSaved(cipherView: CipherView) {
|
||||
this.dialogRef.close({
|
||||
action:
|
||||
this.config.mode === "add"
|
||||
? AddEditCipherDialogResult.Added
|
||||
: AddEditCipherDialogResult.Edited,
|
||||
id: cipherView.id as CipherId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a cipher add/edit dialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param config Configuration for the dialog
|
||||
* @returns A reference to the opened dialog
|
||||
*/
|
||||
export function openAddEditCipherDialog(
|
||||
dialogService: DialogService,
|
||||
config: DialogConfig<CipherFormConfig>,
|
||||
): DialogRef<AddEditCipherDialogCloseResult> {
|
||||
return dialogService.open(AddEditComponentV2, config);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle>
|
||||
{{ "attachments" | i18n }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<app-cipher-attachments
|
||||
*ngIf="cipherId"
|
||||
[cipherId]="cipherId"
|
||||
[submitBtn]="submitBtn"
|
||||
(onUploadSuccess)="uploadSuccessful()"
|
||||
(onRemoveSuccess)="removalSuccessful()"
|
||||
></app-cipher-attachments>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
|
||||
{{ "upload" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
|
@ -0,0 +1,65 @@
|
|||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import {
|
||||
AttachmentsV2Component,
|
||||
AttachmentDialogResult,
|
||||
AttachmentsDialogParams,
|
||||
} from "./attachments-v2.component";
|
||||
|
||||
describe("AttachmentsV2Component", () => {
|
||||
let component: AttachmentsV2Component;
|
||||
let fixture: ComponentFixture<AttachmentsV2Component>;
|
||||
|
||||
const mockCipherId: CipherId = "cipher-id" as CipherId;
|
||||
const mockParams: AttachmentsDialogParams = {
|
||||
cipherId: mockCipherId,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AttachmentsV2Component, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: DIALOG_DATA, useValue: mockParams },
|
||||
{ provide: DialogRef, useValue: mock<DialogRef>() },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AttachmentsV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("initializes without errors and with the correct cipherId", () => {
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.cipherId).toBe(mockParams.cipherId);
|
||||
});
|
||||
|
||||
it("closes the dialog with 'uploaded' result on uploadSuccessful", () => {
|
||||
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||
|
||||
component.uploadSuccessful();
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded });
|
||||
});
|
||||
|
||||
it("closes the dialog with 'removed' result on removalSuccessful", () => {
|
||||
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
|
||||
|
||||
component.removalSuccessful();
|
||||
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { CipherAttachmentsComponent } from "@bitwarden/vault";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
export interface AttachmentsDialogParams {
|
||||
cipherId: CipherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the possible results of the attachment dialog.
|
||||
*/
|
||||
export enum AttachmentDialogResult {
|
||||
Uploaded = "uploaded",
|
||||
Removed = "removed",
|
||||
Closed = "closed",
|
||||
}
|
||||
|
||||
export interface AttachmentDialogCloseResult {
|
||||
action: AttachmentDialogResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the attachments dialog.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-vault-attachments-v2",
|
||||
templateUrl: "attachments-v2.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, SharedModule, CipherAttachmentsComponent],
|
||||
})
|
||||
export class AttachmentsV2Component {
|
||||
cipherId: CipherId;
|
||||
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
|
||||
|
||||
/**
|
||||
* Constructor for AttachmentsV2Component.
|
||||
* @param dialogRef - Reference to the dialog.
|
||||
* @param params - Parameters passed to the dialog.
|
||||
*/
|
||||
constructor(
|
||||
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
|
||||
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
|
||||
) {
|
||||
this.cipherId = params.cipherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the attachments dialog.
|
||||
* @param dialogService - The dialog service.
|
||||
* @param params - The parameters for the dialog.
|
||||
* @returns The dialog reference.
|
||||
*/
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
params: AttachmentsDialogParams,
|
||||
): DialogRef<AttachmentDialogCloseResult> {
|
||||
return dialogService.open(AttachmentsV2Component, {
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully uploaded.
|
||||
* Closes the dialog with an 'uploaded' result.
|
||||
*/
|
||||
uploadSuccessful() {
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Uploaded,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully removed.
|
||||
* Closes the dialog with a 'removed' result.
|
||||
*/
|
||||
removalSuccessful() {
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Removed,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -47,20 +47,25 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
|||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { DialogService, Icons, ToastService } from "@bitwarden/components";
|
||||
import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
CollectionAssignmentResult,
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||
|
@ -74,7 +79,17 @@ import { VaultItemEvent } from "../components/vault-items/vault-item-event";
|
|||
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
|
||||
import { getNestedCollectionTree } from "../utils/collection-utils";
|
||||
|
||||
import {
|
||||
AddEditCipherDialogCloseResult,
|
||||
AddEditCipherDialogResult,
|
||||
openAddEditCipherDialog,
|
||||
} from "./add-edit-v2.component";
|
||||
import { AddEditComponent } from "./add-edit.component";
|
||||
import {
|
||||
AttachmentDialogCloseResult,
|
||||
AttachmentDialogResult,
|
||||
AttachmentsV2Component,
|
||||
} from "./attachments-v2.component";
|
||||
import { AttachmentsComponent } from "./attachments.component";
|
||||
import {
|
||||
BulkDeleteDialogResult,
|
||||
|
@ -131,7 +146,11 @@ const SearchTextDebounceInterval = 200;
|
|||
VaultItemsModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
|
||||
providers: [
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
DefaultCipherFormConfigService,
|
||||
],
|
||||
})
|
||||
export class VaultComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
|
||||
|
@ -170,6 +189,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
private searchText$ = new Subject<string>();
|
||||
private refresh$ = new BehaviorSubject<void>(null);
|
||||
private destroy$ = new Subject<void>();
|
||||
private extensionRefreshEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
|
@ -200,6 +220,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private toastService: ToastService,
|
||||
private accountService: AccountService,
|
||||
private cipherFormConfigService: DefaultCipherFormConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
|
@ -416,6 +437,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
this.refreshing = false;
|
||||
},
|
||||
);
|
||||
|
||||
// Check if the extension refresh feature flag is enabled
|
||||
this.extensionRefreshEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.ExtensionRefresh,
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
@ -511,6 +537,15 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
this.searchText$.next(searchText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles opening the attachments dialog for a cipher.
|
||||
* Runs several checks to ensure that the user has the correct permissions
|
||||
* and then opens the attachments dialog.
|
||||
* Uses the new AttachmentsV2Component if the extensionRefresh feature flag is enabled.
|
||||
*
|
||||
* @param cipher
|
||||
* @returns
|
||||
*/
|
||||
async editCipherAttachments(cipher: CipherView) {
|
||||
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
|
||||
this.go({ cipherId: null, itemId: null });
|
||||
|
@ -536,6 +571,24 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
);
|
||||
|
||||
let madeAttachmentChanges = false;
|
||||
|
||||
if (this.extensionRefreshEnabled) {
|
||||
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
|
||||
cipherId: cipher.id as CipherId,
|
||||
});
|
||||
|
||||
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
if (
|
||||
result.action === AttachmentDialogResult.Uploaded ||
|
||||
result.action === AttachmentDialogResult.Removed
|
||||
) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [modal] = await this.modalService.openViewRef(
|
||||
AttachmentsComponent,
|
||||
this.attachmentsModalRef,
|
||||
|
@ -598,7 +651,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
async addCipher(cipherType?: CipherType) {
|
||||
const component = await this.editCipher(null);
|
||||
if (this.extensionRefreshEnabled) {
|
||||
return this.addCipherV2(cipherType);
|
||||
}
|
||||
|
||||
const component = (await this.editCipher(null)) as AddEditComponent;
|
||||
component.type = cipherType || this.activeFilter.cipherType;
|
||||
if (
|
||||
this.activeFilter.organizationId !== "MyVault" &&
|
||||
|
@ -622,18 +679,60 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
component.folderId = this.activeFilter.folderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the add cipher dialog.
|
||||
* @param cipherType The type of cipher to add.
|
||||
* @returns The dialog reference.
|
||||
*/
|
||||
async addCipherV2(cipherType?: CipherType) {
|
||||
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
|
||||
"add",
|
||||
null,
|
||||
cipherType,
|
||||
);
|
||||
cipherFormConfig.initialValues = {
|
||||
organizationId:
|
||||
this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null
|
||||
? (this.activeFilter.organizationId as OrganizationId)
|
||||
: null,
|
||||
collectionIds:
|
||||
this.activeFilter.collectionId !== "AllCollections" &&
|
||||
this.activeFilter.collectionId != null
|
||||
? [this.activeFilter.collectionId as CollectionId]
|
||||
: [],
|
||||
folderId: this.activeFilter.folderId,
|
||||
};
|
||||
|
||||
// Open the dialog.
|
||||
const dialogRef = openAddEditCipherDialog(this.dialogService, {
|
||||
data: cipherFormConfig,
|
||||
});
|
||||
|
||||
// Wait for the dialog to close.
|
||||
const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
// Refresh the vault to show the new cipher.
|
||||
if (result?.action === AddEditCipherDialogResult.Added) {
|
||||
this.refresh();
|
||||
this.go({ itemId: result.id, action: "view" });
|
||||
return;
|
||||
}
|
||||
|
||||
// If the dialog was closed by any other action navigate back to the vault.
|
||||
this.go({ cipherId: null, itemId: null, action: null });
|
||||
}
|
||||
|
||||
async navigateToCipher(cipher: CipherView) {
|
||||
this.go({ itemId: cipher?.id });
|
||||
}
|
||||
|
||||
async editCipher(cipher: CipherView) {
|
||||
return this.editCipherId(cipher?.id);
|
||||
async editCipher(cipher: CipherView, cloneMode?: boolean) {
|
||||
return this.editCipherId(cipher?.id, cloneMode);
|
||||
}
|
||||
|
||||
async editCipherId(id: string) {
|
||||
async editCipherId(id: string, cloneMode?: boolean) {
|
||||
const cipher = await this.cipherService.get(id);
|
||||
// if cipher exists (cipher is null when new) and MP reprompt
|
||||
// is on for this cipher, then show password reprompt
|
||||
|
||||
if (
|
||||
cipher &&
|
||||
cipher.reprompt !== 0 &&
|
||||
|
@ -644,6 +743,11 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.extensionRefreshEnabled) {
|
||||
await this.editCipherIdV2(cipher, cloneMode);
|
||||
return;
|
||||
}
|
||||
|
||||
const [modal, childComponent] = await this.modalService.openViewRef(
|
||||
AddEditComponent,
|
||||
this.cipherAddEditModalRef,
|
||||
|
@ -673,6 +777,46 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
return childComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a cipher using the new AddEditCipherDialogV2 component.
|
||||
*
|
||||
* @param cipher
|
||||
* @param cloneMode
|
||||
*/
|
||||
private async editCipherIdV2(cipher: Cipher, cloneMode?: boolean) {
|
||||
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
|
||||
cloneMode ? "clone" : "edit",
|
||||
cipher.id as CipherId,
|
||||
cipher.type,
|
||||
);
|
||||
|
||||
const dialogRef = openAddEditCipherDialog(this.dialogService, {
|
||||
data: cipherFormConfig,
|
||||
});
|
||||
|
||||
const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
/**
|
||||
* Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher.
|
||||
*/
|
||||
if (result?.action === AddEditCipherDialogResult.Edited) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* View the cipher if the dialog was closed by editing the cipher.
|
||||
*/
|
||||
if (result?.action === AddEditCipherDialogResult.Edited) {
|
||||
this.go({ itemId: cipher.id, action: "view" });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the vault if the dialog was closed by any other action.
|
||||
*/
|
||||
this.go({ cipherId: null, itemId: null, action: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
|
||||
* @param cipher - CipherView
|
||||
|
@ -718,8 +862,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
// If the dialog was closed by deleting the cipher, refresh the vault.
|
||||
if (result?.action === ViewCipherDialogResult.deleted) {
|
||||
if (result?.action === ViewCipherDialogResult.Deleted) {
|
||||
this.refresh();
|
||||
this.go({ cipherId: null, itemId: null, action: null });
|
||||
}
|
||||
|
||||
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
|
||||
|
@ -873,7 +1018,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
const component = await this.editCipher(cipher);
|
||||
const component = await this.editCipher(cipher, true);
|
||||
component.cloneMode = true;
|
||||
}
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ describe("ViewComponent", () => {
|
|||
organizationId: mockCipher.organizationId,
|
||||
},
|
||||
});
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited });
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -111,7 +111,7 @@ describe("ViewComponent", () => {
|
|||
await component.delete();
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted });
|
||||
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,8 +27,8 @@ export interface ViewCipherDialogParams {
|
|||
}
|
||||
|
||||
export enum ViewCipherDialogResult {
|
||||
edited = "edited",
|
||||
deleted = "deleted",
|
||||
Edited = "edited",
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
export interface ViewCipherDialogCloseResult {
|
||||
|
@ -117,7 +117,7 @@ export class ViewComponent implements OnInit, OnDestroy {
|
|||
this.logService.error(e);
|
||||
}
|
||||
|
||||
this.dialogRef.close({ action: ViewCipherDialogResult.deleted });
|
||||
this.dialogRef.close({ action: ViewCipherDialogResult.Deleted });
|
||||
await this.router.navigate(["/vault"]);
|
||||
};
|
||||
|
||||
|
@ -137,7 +137,7 @@ export class ViewComponent implements OnInit, OnDestroy {
|
|||
* Method to handle cipher editing. Called when a user clicks the edit button.
|
||||
*/
|
||||
async edit(): Promise<void> {
|
||||
this.dialogRef.close({ action: ViewCipherDialogResult.edited });
|
||||
this.dialogRef.close({ action: ViewCipherDialogResult.Edited });
|
||||
await this.router.navigate([], {
|
||||
queryParams: {
|
||||
itemId: this.cipher.id,
|
||||
|
|
|
@ -886,8 +886,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
|
||||
|
||||
// If the dialog was closed by deleting the cipher, refresh the vault.
|
||||
if (result.action === ViewCipherDialogResult.deleted) {
|
||||
if (result.action === ViewCipherDialogResult.Deleted) {
|
||||
this.refresh();
|
||||
this.go({ cipherId: null, itemId: null, action: null });
|
||||
}
|
||||
|
||||
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
|
||||
|
|
|
@ -511,6 +511,24 @@
|
|||
"viewItem": {
|
||||
"message": "View item"
|
||||
},
|
||||
"newItemHeader": {
|
||||
"message": "New $TYPE$",
|
||||
"placeholders": {
|
||||
"type": {
|
||||
"content": "$1",
|
||||
"example": "login"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editItemHeader": {
|
||||
"message": "Edit $TYPE$",
|
||||
"placeholders": {
|
||||
"type": {
|
||||
"content": "$1",
|
||||
"example": "login"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewItemType": {
|
||||
"message": "View $ITEMTYPE$",
|
||||
"placeholders": {
|
||||
|
@ -7399,6 +7417,9 @@
|
|||
"fileUpload": {
|
||||
"message": "File upload"
|
||||
},
|
||||
"upload": {
|
||||
"message": "Upload"
|
||||
},
|
||||
"acceptedFormats": {
|
||||
"message": "Accepted Formats:"
|
||||
},
|
||||
|
|
|
@ -85,6 +85,9 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
|||
/** Emits after a file has been successfully uploaded */
|
||||
@Output() onUploadSuccess = new EventEmitter<void>();
|
||||
|
||||
/** Emits after a file has been successfully removed */
|
||||
@Output() onRemoveSuccess = new EventEmitter<void>();
|
||||
|
||||
cipher: CipherView;
|
||||
|
||||
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
|
||||
|
@ -216,5 +219,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
|||
if (index > -1) {
|
||||
this.cipher.attachments.splice(index, 1);
|
||||
}
|
||||
|
||||
this.onRemoveSuccess.emit();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
|||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
@ -39,6 +40,8 @@ describe("LoginDetailsSectionComponent", () => {
|
|||
let toastService: MockProxy<ToastService>;
|
||||
let totpCaptureService: MockProxy<TotpCaptureService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -49,6 +52,7 @@ describe("LoginDetailsSectionComponent", () => {
|
|||
toastService = mock<ToastService>();
|
||||
totpCaptureService = mock<TotpCaptureService>();
|
||||
i18nService = mock<I18nService>();
|
||||
configService = mock<ConfigService>();
|
||||
collect.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
|
@ -60,6 +64,7 @@ describe("LoginDetailsSectionComponent", () => {
|
|||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: TotpCaptureService, useValue: totpCaptureService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
{ provide: EventCollectionService, useValue: { collect } },
|
||||
],
|
||||
})
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle>
|
||||
{{ title }}
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<vault-cipher-form-generator
|
||||
[type]="params.type"
|
||||
(valueGenerated)="onValueGenerated($event)"
|
||||
></vault-cipher-form-generator>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="selectValue()"
|
||||
data-testid="select-button"
|
||||
>
|
||||
{{ selectButtonText }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
|
@ -0,0 +1,125 @@
|
|||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { UsernameGenerationServiceAbstraction } from "../../../../../../libs/tools/generator/extensions/legacy/src/username-generation.service.abstraction";
|
||||
import { CipherFormGeneratorComponent } from "../cipher-generator/cipher-form-generator.component";
|
||||
|
||||
import {
|
||||
WebVaultGeneratorDialogComponent,
|
||||
WebVaultGeneratorDialogParams,
|
||||
WebVaultGeneratorDialogAction,
|
||||
} from "./web-generator-dialog.component";
|
||||
|
||||
describe("WebVaultGeneratorDialogComponent", () => {
|
||||
let component: WebVaultGeneratorDialogComponent;
|
||||
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
|
||||
|
||||
let dialogRef: MockProxy<DialogRef<any>>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let passwordOptionsSubject: BehaviorSubject<any>;
|
||||
let usernameOptionsSubject: BehaviorSubject<any>;
|
||||
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let mockUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
|
||||
|
||||
beforeEach(async () => {
|
||||
dialogRef = mock<DialogRef<any>>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]);
|
||||
usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]);
|
||||
|
||||
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||
mockPasswordGenerationService.getOptions$.mockReturnValue(
|
||||
passwordOptionsSubject.asObservable(),
|
||||
);
|
||||
|
||||
mockUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
|
||||
mockUsernameGenerationService.getOptions$.mockReturnValue(
|
||||
usernameOptionsSubject.asObservable(),
|
||||
);
|
||||
|
||||
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: dialogRef,
|
||||
},
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: mockDialogData,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: mockI18nService,
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: mock<PlatformUtilsService>(),
|
||||
},
|
||||
{
|
||||
provide: PasswordGenerationServiceAbstraction,
|
||||
useValue: mockPasswordGenerationService,
|
||||
},
|
||||
{
|
||||
provide: UsernameGenerationServiceAbstraction,
|
||||
useValue: mockUsernameGenerationService,
|
||||
},
|
||||
{
|
||||
provide: CipherFormGeneratorComponent,
|
||||
useValue: {
|
||||
passwordOptions$: passwordOptionsSubject.asObservable(),
|
||||
usernameOptions$: usernameOptionsSubject.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("initializes without errors", () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the dialog with 'canceled' result when close is called", () => {
|
||||
const closeSpy = jest.spyOn(dialogRef, "close");
|
||||
|
||||
(component as any).close();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledWith({
|
||||
action: WebVaultGeneratorDialogAction.Canceled,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the dialog with 'selected' result when selectValue is called", () => {
|
||||
const closeSpy = jest.spyOn(dialogRef, "close");
|
||||
const generatedValue = "generated-value";
|
||||
component.onValueGenerated(generatedValue);
|
||||
|
||||
(component as any).selectValue();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledWith({
|
||||
action: WebVaultGeneratorDialogAction.Selected,
|
||||
generatedValue: generatedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates generatedValue when onValueGenerated is called", () => {
|
||||
const generatedValue = "new-generated-value";
|
||||
component.onValueGenerated(generatedValue);
|
||||
|
||||
expect((component as any).generatedValue).toBe(generatedValue);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ButtonModule, DialogService } from "@bitwarden/components";
|
||||
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
|
||||
|
||||
import { DialogModule } from "../../../../../../libs/components/src/dialog";
|
||||
|
||||
export interface WebVaultGeneratorDialogParams {
|
||||
type: "password" | "username";
|
||||
}
|
||||
|
||||
export interface WebVaultGeneratorDialogResult {
|
||||
action: WebVaultGeneratorDialogAction;
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
export enum WebVaultGeneratorDialogAction {
|
||||
Selected = "selected",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "web-vault-generator-dialog",
|
||||
templateUrl: "./web-generator-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule],
|
||||
})
|
||||
export class WebVaultGeneratorDialogComponent {
|
||||
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator");
|
||||
protected selectButtonText = this.i18nService.t(
|
||||
this.isPassword ? "useThisPassword" : "useThisUsername",
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the dialog is generating a password/passphrase. If false, it is generating a username.
|
||||
* @protected
|
||||
*/
|
||||
protected get isPassword() {
|
||||
return this.params.type === "password";
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently generated value.
|
||||
* @protected
|
||||
*/
|
||||
protected generatedValue: string = "";
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams,
|
||||
private dialogRef: DialogRef<WebVaultGeneratorDialogResult>,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Close the dialog without selecting a value.
|
||||
*/
|
||||
protected close = () => {
|
||||
this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the dialog and select the currently generated value.
|
||||
*/
|
||||
protected selectValue = () => {
|
||||
this.dialogRef.close({
|
||||
action: WebVaultGeneratorDialogAction.Selected,
|
||||
generatedValue: this.generatedValue,
|
||||
});
|
||||
};
|
||||
|
||||
onValueGenerated(value: string) {
|
||||
this.generatedValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the vault generator dialog.
|
||||
*/
|
||||
static open(dialogService: DialogService, config: DialogConfig<WebVaultGeneratorDialogParams>) {
|
||||
return dialogService.open<WebVaultGeneratorDialogResult, WebVaultGeneratorDialogParams>(
|
||||
WebVaultGeneratorDialogComponent,
|
||||
{
|
||||
...config,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
|
||||
|
||||
import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service";
|
||||
|
||||
describe("WebCipherFormGenerationService", () => {
|
||||
let service: WebCipherFormGenerationService;
|
||||
let dialogService: jest.Mocked<DialogService>;
|
||||
let closed = of({});
|
||||
const close = jest.fn();
|
||||
const dialogRef = {
|
||||
close,
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
} as unknown as DialogRef<unknown, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WebCipherFormGenerationService,
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(WebCipherFormGenerationService);
|
||||
});
|
||||
|
||||
it("creates without error", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("generatePassword", () => {
|
||||
it("opens the password generator dialog and returns the generated value", async () => {
|
||||
const generatedValue = "generated-password";
|
||||
closed = of({ action: "generated", generatedValue });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generatePassword();
|
||||
|
||||
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
|
||||
data: { type: "password" },
|
||||
});
|
||||
expect(result).toBe(generatedValue);
|
||||
});
|
||||
|
||||
it("returns null if the dialog is canceled", async () => {
|
||||
closed = of({ action: "canceled" });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generatePassword();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUsername", () => {
|
||||
it("opens the username generator dialog and returns the generated value", async () => {
|
||||
const generatedValue = "generated-username";
|
||||
closed = of({ action: "generated", generatedValue });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generateUsername();
|
||||
|
||||
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
|
||||
data: { type: "username" },
|
||||
});
|
||||
expect(result).toBe(generatedValue);
|
||||
});
|
||||
|
||||
it("returns null if the dialog is canceled", async () => {
|
||||
closed = of({ action: "canceled" });
|
||||
dialogService.open.mockReturnValue(dialogRef);
|
||||
|
||||
const result = await service.generateUsername();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
import { inject, Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { CipherFormGenerationService } from "@bitwarden/vault";
|
||||
|
||||
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
|
||||
|
||||
@Injectable()
|
||||
export class WebCipherFormGenerationService implements CipherFormGenerationService {
|
||||
private dialogService = inject(DialogService);
|
||||
|
||||
async generatePassword(): Promise<string> {
|
||||
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
|
||||
data: { type: "password" },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == null || result.action === "canceled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.generatedValue;
|
||||
}
|
||||
|
||||
async generateUsername(): Promise<string> {
|
||||
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
|
||||
data: { type: "username" },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == null || result.action === "canceled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.generatedValue;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue