1
0
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:
Shane Melton 2024-08-07 12:02:33 -07:00 committed by GitHub
parent 6179397ba9
commit 041cd87e7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 720 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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