mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-03 18:28:13 +01: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:
parent
6179397ba9
commit
041cd87e7e
@ -1803,6 +1803,18 @@
|
||||
"passwordGeneratorPolicyInEffect": {
|
||||
"message": "One or more organization policies are affecting your generator settings."
|
||||
},
|
||||
"passwordGenerator": {
|
||||
"message": "Password generator"
|
||||
},
|
||||
"usernameGenerator": {
|
||||
"message": "Username generator"
|
||||
},
|
||||
"useThisPassword": {
|
||||
"message": "Use this password"
|
||||
},
|
||||
"useThisUsername": {
|
||||
"message": "Use this username"
|
||||
},
|
||||
"vaultTimeoutAction": {
|
||||
"message": "Vault timeout action"
|
||||
},
|
||||
|
@ -14,7 +14,7 @@
|
||||
type="button"
|
||||
*ngIf="showBackButton"
|
||||
[title]="'back' | i18n"
|
||||
[ariaLabel]="'back' | i18n"
|
||||
[attr.aria-label]="'back' | i18n"
|
||||
[bitAction]="backAction"
|
||||
></button>
|
||||
<h1 *ngIf="pageTitle" bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">
|
||||
|
@ -14,6 +14,7 @@ import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/compo
|
||||
import {
|
||||
CipherFormConfig,
|
||||
CipherFormConfigService,
|
||||
CipherFormGenerationService,
|
||||
CipherFormMode,
|
||||
CipherFormModule,
|
||||
DefaultCipherFormConfigService,
|
||||
@ -27,6 +28,7 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup
|
||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||
import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service";
|
||||
import { BrowserFido2UserInterfaceSession } from "../../../../fido2/browser-fido2-user-interface.service";
|
||||
import { BrowserCipherFormGenerationService } from "../../../services/browser-cipher-form-generation.service";
|
||||
import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service";
|
||||
import {
|
||||
fido2PopoutSessionData$,
|
||||
@ -106,6 +108,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
||||
providers: [
|
||||
{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService },
|
||||
{ provide: TotpCaptureService, useClass: BrowserTotpCaptureService },
|
||||
{ provide: CipherFormGenerationService, useClass: BrowserCipherFormGenerationService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -0,0 +1,25 @@
|
||||
<popup-page @slideIn>
|
||||
<popup-header
|
||||
slot="header"
|
||||
[backAction]="close"
|
||||
showBackButton
|
||||
[pageTitle]="title"
|
||||
></popup-header>
|
||||
|
||||
<vault-cipher-form-generator
|
||||
[type]="params.type"
|
||||
(valueGenerated)="onValueGenerated($event)"
|
||||
></vault-cipher-form-generator>
|
||||
|
||||
<popup-footer slot="footer">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="selectValue()"
|
||||
data-testid="select-button"
|
||||
>
|
||||
{{ selectButtonText }}
|
||||
</button>
|
||||
</popup-footer>
|
||||
</popup-page>
|
@ -0,0 +1,82 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
|
||||
|
||||
import {
|
||||
GeneratorDialogParams,
|
||||
GeneratorDialogResult,
|
||||
VaultGeneratorDialogComponent,
|
||||
} from "./vault-generator-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-cipher-form-generator",
|
||||
template: "",
|
||||
standalone: true,
|
||||
})
|
||||
class MockCipherFormGenerator {
|
||||
@Input() type: "password" | "username";
|
||||
@Output() valueGenerated = new EventEmitter<string>();
|
||||
}
|
||||
|
||||
describe("VaultGeneratorDialogComponent", () => {
|
||||
let component: VaultGeneratorDialogComponent;
|
||||
let fixture: ComponentFixture<VaultGeneratorDialogComponent>;
|
||||
let mockDialogRef: MockProxy<DialogRef<GeneratorDialogResult>>;
|
||||
let dialogData: GeneratorDialogParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = mock<DialogRef<GeneratorDialogResult>>();
|
||||
dialogData = { type: "password" };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultGeneratorDialogComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: DIALOG_DATA, useValue: dialogData },
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
],
|
||||
})
|
||||
.overrideComponent(VaultGeneratorDialogComponent, {
|
||||
remove: { imports: [CipherFormGeneratorComponent] },
|
||||
add: { imports: [MockCipherFormGenerator] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultGeneratorDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should use the appropriate text based on generator type", () => {
|
||||
expect(component["title"]).toBe("passwordGenerator");
|
||||
expect(component["selectButtonText"]).toBe("useThisPassword");
|
||||
|
||||
dialogData.type = "username";
|
||||
|
||||
fixture = TestBed.createComponent(VaultGeneratorDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
expect(component["title"]).toBe("usernameGenerator");
|
||||
expect(component["selectButtonText"]).toBe("useThisUsername");
|
||||
});
|
||||
|
||||
it("should close the dialog with the generated value when the user selects it", () => {
|
||||
component["generatedValue"] = "generated-value";
|
||||
|
||||
fixture.nativeElement.querySelector("button[data-testid='select-button']").click();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
action: "selected",
|
||||
generatedValue: "generated-value",
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,120 @@
|
||||
import { animate, group, style, transition, trigger } from "@angular/animations";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
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 { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
export interface GeneratorDialogParams {
|
||||
type: "password" | "username";
|
||||
}
|
||||
|
||||
export interface GeneratorDialogResult {
|
||||
action: GeneratorDialogAction;
|
||||
generatedValue?: string;
|
||||
}
|
||||
|
||||
export enum GeneratorDialogAction {
|
||||
Selected = "selected",
|
||||
Canceled = "canceled",
|
||||
}
|
||||
|
||||
const slideIn = trigger("slideIn", [
|
||||
transition(":enter", [
|
||||
style({ opacity: 0, transform: "translateY(100vh)" }),
|
||||
group([
|
||||
animate("0.15s linear", style({ opacity: 1 })),
|
||||
animate("0.3s ease-out", style({ transform: "none" })),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-generator-dialog",
|
||||
templateUrl: "./vault-generator-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
PopupPageComponent,
|
||||
PopupHeaderComponent,
|
||||
PopupFooterComponent,
|
||||
CommonModule,
|
||||
CipherFormGeneratorComponent,
|
||||
ButtonModule,
|
||||
],
|
||||
animations: [slideIn],
|
||||
})
|
||||
export class VaultGeneratorDialogComponent {
|
||||
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: GeneratorDialogParams,
|
||||
private dialogRef: DialogRef<GeneratorDialogResult>,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Close the dialog without selecting a value.
|
||||
*/
|
||||
protected close = () => {
|
||||
this.dialogRef.close({ action: GeneratorDialogAction.Canceled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the dialog and select the currently generated value.
|
||||
*/
|
||||
protected selectValue = () => {
|
||||
this.dialogRef.close({
|
||||
action: GeneratorDialogAction.Selected,
|
||||
generatedValue: this.generatedValue,
|
||||
});
|
||||
};
|
||||
|
||||
onValueGenerated(value: string) {
|
||||
this.generatedValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the vault generator dialog in a full screen dialog.
|
||||
*/
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
overlay: Overlay,
|
||||
config: DialogConfig<GeneratorDialogParams>,
|
||||
) {
|
||||
const position = overlay.position().global();
|
||||
|
||||
return dialogService.open<GeneratorDialogResult, GeneratorDialogParams>(
|
||||
VaultGeneratorDialogComponent,
|
||||
{
|
||||
...config,
|
||||
positionStrategy: position,
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import { Overlay } from "@angular/cdk/overlay";
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { CipherFormGenerationService } from "@bitwarden/vault";
|
||||
|
||||
import { VaultGeneratorDialogComponent } from "../components/vault-v2/vault-generator-dialog/vault-generator-dialog.component";
|
||||
|
||||
@Injectable()
|
||||
export class BrowserCipherFormGenerationService implements CipherFormGenerationService {
|
||||
private dialogService = inject(DialogService);
|
||||
private overlay = inject(Overlay);
|
||||
|
||||
async generatePassword(): Promise<string> {
|
||||
const dialogRef = VaultGeneratorDialogComponent.open(this.dialogService, this.overlay, {
|
||||
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 = VaultGeneratorDialogComponent.open(this.dialogService, this.overlay, {
|
||||
data: { type: "username" },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result == null || result.action === "canceled") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.generatedValue;
|
||||
}
|
||||
async generateInitialPassword(): Promise<string> {
|
||||
return "";
|
||||
}
|
||||
}
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
};
|
||||
}
|
@ -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";
|
||||
|
Loading…
Reference in New Issue
Block a user