1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-10-08 05:47:50 +02:00

integrate username and password generators into browser extension

This commit is contained in:
Nick Krantz 2024-10-01 15:15:18 -05:00
parent abf7a5146f
commit 5619504778
No known key found for this signature in database
GPG Key ID: FF670021ABCAB82E
3 changed files with 84 additions and 395 deletions

View File

@ -1,62 +1,10 @@
<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>
<tools-password-generator
*ngIf="type === 'password'"
(onGenerated)="onCredentialGenerated($event)"
></tools-password-generator>
<tools-username-generator
*ngIf="type === 'username'"
(onGenerated)="onCredentialGenerated($event)"
></tools-username-generator>
</bit-section>

View File

@ -1,217 +1,103 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { Component, EventEmitter, Output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
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";
PasswordGeneratorComponent,
UsernameGeneratorComponent,
} from "@bitwarden/generator-components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
@Component({
selector: "tools-password-generator",
template: `<ng-content></ng-content>`,
standalone: true,
})
class MockPasswordGeneratorComponent {
@Output() onGenerated = new EventEmitter();
}
@Component({
selector: "tools-username-generator",
template: `<ng-content></ng-content>`,
standalone: true,
})
class MockUsernameGeneratorComponent {
@Output() onGenerated = new EventEmitter();
}
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();
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
})
.overrideComponent(CipherFormGeneratorComponent, {
remove: { imports: [PasswordGeneratorComponent, UsernameGeneratorComponent] },
add: { imports: [MockPasswordGeneratorComponent, MockUsernameGeneratorComponent] },
})
.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", () => {
let passwordGenerator: MockPasswordGeneratorComponent;
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();
passwordGenerator = fixture.debugElement.query(
By.directive(MockPasswordGeneratorComponent),
).componentInstance;
});
it("should update the generated value when the password type is updated", fakeAsync(async () => {
mockLegacyPasswordGenerationService.generatePassword
.mockResolvedValueOnce("first-password")
.mockResolvedValueOnce("second-password");
it("only shows `PasswordGeneratorComponent`", () => {
expect(passwordGenerator).toBeTruthy();
expect(fixture.debugElement.query(By.directive(MockUsernameGeneratorComponent))).toBeNull();
});
component.ngOnChanges();
tick();
it("invokes `valueGenerated` with the generated credential", () => {
jest.spyOn(component.valueGenerated, "emit");
expect(component["generatedValue"]).toBe("first-password");
passwordGenerator.onGenerated.emit({ credential: "new-cred-password!" });
await component["updatePasswordType"]("passphrase");
tick();
expect(component["generatedValue"]).toBe("second-password");
expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
}));
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");
}));
expect(component.valueGenerated.emit).toHaveBeenCalledTimes(1);
expect(component.valueGenerated.emit).toHaveBeenCalledWith("new-cred-password!");
});
});
describe("username generation", () => {
let usernameGenerator: MockUsernameGeneratorComponent;
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();
usernameGenerator = fixture.debugElement.query(
By.directive(MockUsernameGeneratorComponent),
).componentInstance;
});
it("only shows `UsernameGeneratorComponent`", () => {
expect(usernameGenerator).toBeTruthy();
expect(fixture.debugElement.query(By.directive(MockPasswordGeneratorComponent))).toBeNull();
});
it("invokes `valueGenerated` with the generated credential", () => {
jest.spyOn(component.valueGenerated, "emit");
usernameGenerator.onGenerated.emit({ credential: "new-cred-username!" });
expect(component.valueGenerated.emit).toHaveBeenCalledTimes(1);
expect(component.valueGenerated.emit).toHaveBeenCalledWith("new-cred-username!");
});
});
});

View File

@ -1,36 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
combineLatest,
map,
merge,
shareReplay,
startWith,
Subject,
Subscription,
switchMap,
take,
tap,
} from "rxjs";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SectionComponent } from "@bitwarden/components";
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";
PasswordGeneratorComponent,
UsernameGeneratorComponent,
} from "@bitwarden/generator-components";
import { GeneratedCredential } from "@bitwarden/generator-core";
/**
* Renders a password or username generator UI and emits the most recently generated value.
@ -40,20 +16,9 @@ import {
selector: "vault-cipher-form-generator",
templateUrl: "./cipher-form-generator.component.html",
standalone: true,
imports: [
CommonModule,
CardComponent,
SectionComponent,
ToggleGroupModule,
JslibModule,
ItemModule,
ColorPasswordModule,
IconButtonModule,
SectionHeaderComponent,
TypographyModule,
],
imports: [CommonModule, SectionComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
})
export class CipherFormGeneratorComponent implements OnChanges {
export class CipherFormGeneratorComponent {
/**
* The type of generator form to show.
*/
@ -66,118 +31,8 @@ export class CipherFormGeneratorComponent implements OnChanges {
@Output()
valueGenerated = new EventEmitter<string>();
protected get isPassword() {
return this.type === "password";
}
protected regenerateButtonTitle: string;
protected regenerate$ = new Subject<void>();
protected passwordTypeSubject$ = new Subject<GeneratorType>();
/**
* The currently generated value displayed to the user.
* @protected
*/
protected generatedValue: string = "";
/**
* The current username generation options.
* @private
*/
private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$();
/**
* The current password type selected in the UI. Starts with the saved value from the service.
* @protected
*/
protected passwordType$ = merge(
this.legacyPasswordGenerationService.getOptions$().pipe(
take(1),
map(([options]) => options.type),
),
this.passwordTypeSubject$,
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
/**
* The current password generation options.
* @private
*/
private passwordOptions$ = combineLatest([
this.legacyPasswordGenerationService.getOptions$(),
this.passwordType$,
]).pipe(
map(([[options], type]) => {
options.type = type;
return options;
}),
);
/**
* 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.
* @param value The new password generation type.
*/
protected updatePasswordType = async (value: GeneratorType) => {
this.passwordTypeSubject$.next(value);
/** Event handler for both generation components */
onCredentialGenerated = (generatedCred: GeneratedCredential) => {
this.valueGenerated.emit(generatedCred.credential);
};
}