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:
parent
abf7a5146f
commit
5619504778
@ -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>
|
||||
|
@ -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!");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user