1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-9675] Browser Refresh Login - Generator dialog (#10352)

* [PM-9675] Introduce CipherFormGenerator component

* [PM-9675] Introduce VaultGeneratorDialog component for Browser

* [PM-9675] Introduce BrowserCipherFormGeneration Service

* [PM-9675] Fix aria label on popup header

* [PM-9675] Cleanup html

* [PM-9675] Cleanup vault generator dialog spec file
This commit is contained in:
Shane Melton
2024-08-07 12:02:33 -07:00
committed by GitHub
parent 6179397ba9
commit 041cd87e7e
11 changed files with 720 additions and 1 deletions

View File

@@ -0,0 +1,62 @@
<bit-section>
<!-- Password/Passphrase Toggle -->
<bit-toggle-group
*ngIf="isPassword"
class="tw-w-full tw-justify-center tw-mt-3 tw-mb-5"
(selectedChange)="updatePasswordType($event)"
[selected]="passwordType$ | async"
>
<bit-toggle [value]="'password'">
{{ "password" | i18n }}
</bit-toggle>
<bit-toggle [value]="'passphrase'">
{{ "passphrase" | i18n }}
</bit-toggle>
</bit-toggle-group>
<!-- Generated Password/Passphrase/Username -->
<bit-item>
<bit-item-content>
<bit-color-password [password]="generatedValue"></bit-color-password>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[appCopyClick]="generatedValue"
showToast
[appA11yTitle]="'copyValue' | i18n"
data-testid="copy-value-button"
></button>
</bit-item-action>
<bit-item-action>
<button
type="button"
bitIconButton="bwi-generate"
size="small"
(click)="regenerate$.next()"
[appA11yTitle]="regenerateButtonTitle"
data-testid="regenerate-button"
></button>
</bit-item-action>
</ng-container>
</bit-item>
</bit-section>
<!-- Generator Options -->
<!-- TODO: Replace with Generator Options Component(s) when available
It is expected that the generator options component(s) will internally update the options stored in state
which will trigger regeneration automatically in this dialog.
-->
<bit-section>
<bit-section-header>
<h2 bitTypography="h5">{{ "options" | i18n }}</h2>
</bit-section-header>
<bit-card>
<em bitTypography="body2"
>Placeholder: Replace with Generator Options Component(s) when available</em
>
</bit-card>
</bit-section>

View File

@@ -0,0 +1,210 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
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,
PasswordGeneratorOptions,
UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions,
} from "@bitwarden/generator-legacy";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
describe("CipherFormGeneratorComponent", () => {
let component: CipherFormGeneratorComponent;
let fixture: ComponentFixture<CipherFormGeneratorComponent>;
let mockLegacyPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockLegacyUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let passwordOptions$: BehaviorSubject<any>;
let usernameOptions$: BehaviorSubject<any>;
beforeEach(async () => {
passwordOptions$ = new BehaviorSubject([
{
type: "password",
},
] as [PasswordGeneratorOptions]);
usernameOptions$ = new BehaviorSubject([
{
type: "word",
},
] as [UsernameGeneratorOptions]);
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockLegacyPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockLegacyPasswordGenerationService.getOptions$.mockReturnValue(passwordOptions$);
mockLegacyUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
mockLegacyUsernameGenerationService.getOptions$.mockReturnValue(usernameOptions$);
await TestBed.configureTestingModule({
imports: [CipherFormGeneratorComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockLegacyPasswordGenerationService,
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mockLegacyUsernameGenerationService,
},
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
],
}).compileComponents();
fixture = TestBed.createComponent(CipherFormGeneratorComponent);
component = fixture.componentInstance;
});
it("should create", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("should use the appropriate text based on generator type", () => {
component.type = "password";
component.ngOnChanges();
expect(component["regenerateButtonTitle"]).toBe("regeneratePassword");
component.type = "username";
component.ngOnChanges();
expect(component["regenerateButtonTitle"]).toBe("regenerateUsername");
});
it("should emit regenerate$ when user clicks the regenerate button", fakeAsync(() => {
const regenerateSpy = jest.spyOn(component["regenerate$"], "next");
fixture.nativeElement.querySelector("button[data-testid='regenerate-button']").click();
expect(regenerateSpy).toHaveBeenCalled();
}));
it("should emit valueGenerated whenever a new value is generated", fakeAsync(() => {
const valueGeneratedSpy = jest.spyOn(component.valueGenerated, "emit");
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password");
component.type = "password";
component.ngOnChanges();
tick();
expect(valueGeneratedSpy).toHaveBeenCalledWith("generated-password");
}));
describe("password generation", () => {
beforeEach(() => {
component.type = "password";
});
it("should update the generated value when the password options change", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-password");
passwordOptions$.next([{ type: "password" }]);
tick();
expect(component["generatedValue"]).toBe("second-password");
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
}));
it("should show password type toggle when the generator type is password", () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy();
});
it("should save password options when the password type is updated", async () => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password");
await component["updatePasswordType"]("passphrase");
expect(mockLegacyPasswordGenerationService.saveOptions).toHaveBeenCalledWith({
type: "passphrase",
});
});
it("should update the password history when a new password is generated", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password");
component.ngOnChanges();
tick();
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(1);
expect(mockLegacyPasswordGenerationService.addHistory).toHaveBeenCalledWith("new-password");
expect(component["generatedValue"]).toBe("new-password");
}));
it("should regenerate the password when regenerate$ emits", fakeAsync(() => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-password");
component["regenerate$"].next();
tick();
expect(component["generatedValue"]).toBe("second-password");
}));
});
describe("username generation", () => {
beforeEach(() => {
component.type = "username";
});
it("should update the generated value when the username options change", fakeAsync(() => {
mockLegacyUsernameGenerationService.generateUsername
.mockResolvedValueOnce("first-username")
.mockResolvedValueOnce("second-username");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-username");
usernameOptions$.next([{ type: "word" }]);
tick();
expect(component["generatedValue"]).toBe("second-username");
}));
it("should regenerate the username when regenerate$ emits", fakeAsync(() => {
mockLegacyUsernameGenerationService.generateUsername
.mockResolvedValueOnce("first-username")
.mockResolvedValueOnce("second-username");
component.ngOnChanges();
tick();
expect(component["generatedValue"]).toBe("first-username");
component["regenerate$"].next();
tick();
expect(component["generatedValue"]).toBe("second-username");
}));
it("should not show password type toggle when the generator type is username", () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeNull();
});
});
});

View File

@@ -0,0 +1,159 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, EventEmitter, Input, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, startWith, Subject, Subscription, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
CardComponent,
ColorPasswordModule,
IconButtonModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
ToggleGroupModule,
TypographyModule,
} from "@bitwarden/components";
import { GeneratorType } from "@bitwarden/generator-core";
import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
/**
* Renders a password or username generator UI and emits the most recently generated value.
* Used by the cipher form to be shown in a dialog/modal when generating cipher passwords/usernames.
*/
@Component({
selector: "vault-cipher-form-generator",
templateUrl: "./cipher-form-generator.component.html",
standalone: true,
imports: [
CommonModule,
CardComponent,
SectionComponent,
ToggleGroupModule,
JslibModule,
ItemModule,
ColorPasswordModule,
IconButtonModule,
SectionHeaderComponent,
TypographyModule,
],
})
export class CipherFormGeneratorComponent {
/**
* The type of generator form to show.
*/
@Input({ required: true })
type: "password" | "username";
/**
* Emits an event when a new value is generated.
*/
@Output()
valueGenerated = new EventEmitter<string>();
protected get isPassword() {
return this.type === "password";
}
protected regenerateButtonTitle: string;
protected regenerate$ = new Subject<void>();
/**
* The currently generated value displayed to the user.
* @protected
*/
protected generatedValue: string = "";
/**
* The current password generation options.
* @private
*/
private passwordOptions$ = this.legacyPasswordGenerationService.getOptions$();
/**
* The current username generation options.
* @private
*/
private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$();
/**
* The current password type specified by the password generation options.
* @protected
*/
protected passwordType$ = this.passwordOptions$.pipe(map(([options]) => options.type));
/**
* Tracks the regenerate$ subscription
* @private
*/
private subscription: Subscription | null;
constructor(
private i18nService: I18nService,
private legacyPasswordGenerationService: PasswordGenerationServiceAbstraction,
private legacyUsernameGenerationService: UsernameGenerationServiceAbstraction,
private destroyRef: DestroyRef,
) {}
ngOnChanges() {
this.regenerateButtonTitle = this.i18nService.t(
this.isPassword ? "regeneratePassword" : "regenerateUsername",
);
// If we have a previous subscription, clear it
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
}
if (this.isPassword) {
this.setupPasswordGeneration();
} else {
this.setupUsernameGeneration();
}
}
private setupPasswordGeneration() {
this.subscription = this.regenerate$
.pipe(
startWith(null),
switchMap(() => this.passwordOptions$),
switchMap(([options]) => this.legacyPasswordGenerationService.generatePassword(options)),
tap(async (password) => {
await this.legacyPasswordGenerationService.addHistory(password);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((password) => {
this.generatedValue = password;
this.valueGenerated.emit(password);
});
}
private setupUsernameGeneration() {
this.subscription = this.regenerate$
.pipe(
startWith(null),
switchMap(() => this.usernameOptions$),
switchMap((options) => this.legacyUsernameGenerationService.generateUsername(options)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((username) => {
this.generatedValue = username;
this.valueGenerated.emit(username);
});
}
/**
* Switch the password generation type and save the options (generating a new password automatically).
* @param value The new password generation type.
*/
protected updatePasswordType = async (value: GeneratorType) => {
const [currentOptions] = await firstValueFrom(this.passwordOptions$);
currentOptions.type = value;
await this.legacyPasswordGenerationService.saveOptions(currentOptions);
};
}

View File

@@ -8,3 +8,4 @@ export {
export { TotpCaptureService } from "./abstractions/totp-capture.service";
export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service";
export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service";
export { CipherFormGeneratorComponent } from "./components/cipher-generator/cipher-form-generator.component";