1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[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
18 changed files with 1065 additions and 18 deletions

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