[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:
Alec Rippberger 2024-09-18 12:48:47 -05:00 committed by GitHub
parent a674f698a2
commit 931f86c948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1065 additions and 18 deletions

View File

@ -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>

View File

@ -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();
});
});
});

View File

@ -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);
}

View File

@ -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>

View File

@ -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 });
});
});

View File

@ -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,
});
}
}

View File

@ -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;
}

View File

@ -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 });
});
});
});

View File

@ -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,

View File

@ -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.

View File

@ -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:"
},

View File

@ -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();
}
}

View File

@ -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 } },
],
})

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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,
},
);
}
}

View File

@ -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();
});
});
});

View File

@ -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;
}
}