From dc1f014ad8b8bd1e1de3868f8ad321747235f44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 8 Oct 2024 14:08:34 -0400 Subject: [PATCH] [PM-8282] credential generator (#11398) * credential generator browser ui * switch browser generate screen to extension refresh flag * consolidate generator components into module * add `@bitwarden/generator-components` readme * normalize generator component rx subscriptions --- apps/browser/src/_locales/en/messages.json | 2 +- apps/browser/src/popup/app-routing.module.ts | 3 +- .../credential-generator.component.html | 17 +- .../credential-generator.component.ts | 22 +- .../credential-generator.component.ts | 4 +- apps/desktop/src/locales/en/messages.json | 3 +- apps/web/src/locales/en/messages.json | 3 +- libs/tools/generator/components/readme.md | 40 +++ .../src/catchall-settings.component.ts | 3 - .../src/credential-generator.component.html | 77 +++++ .../src/credential-generator.component.ts | 293 ++++++++++++++++++ .../{dependencies.ts => generator.module.ts} | 30 +- libs/tools/generator/components/src/index.ts | 8 +- .../src/passphrase-settings.component.ts | 3 - .../src/password-generator.component.html | 12 +- .../src/password-generator.component.ts | 105 ++++++- .../src/password-settings.component.ts | 3 - .../src/subaddress-settings.component.ts | 3 - .../src/username-generator.component.html | 1 + .../src/username-generator.component.ts | 42 +-- .../src/username-settings.component.ts | 3 - .../cipher-form-generator.component.spec.ts | 7 +- .../cipher-form-generator.component.ts | 8 +- 23 files changed, 592 insertions(+), 100 deletions(-) create mode 100644 libs/tools/generator/components/readme.md create mode 100644 libs/tools/generator/components/src/credential-generator.component.html create mode 100644 libs/tools/generator/components/src/credential-generator.component.ts rename libs/tools/generator/components/src/{dependencies.ts => generator.module.ts} (62%) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ec0fac137d..888aa0e9a8 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -381,7 +381,7 @@ }, "generator": { "message": "Generator", - "description": "Short for 'Password Generator'." + "description": "Short for 'credential generator'." }, "passGenInfo": { "message": "Automatically generate strong, unique passwords for your logins." diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 9fd52470c0..e07201c78d 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -11,7 +11,6 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; -import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap"; import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; import { @@ -555,7 +554,7 @@ const routes: Routes = [ canDeactivate: [clearVaultStateGuard], data: { state: "tabs_vault" } satisfies RouteDataProperties, }), - ...generatorSwap(GeneratorComponent, CredentialGeneratorComponent, { + ...extensionRefreshSwap(GeneratorComponent, CredentialGeneratorComponent, { path: "generator", canActivate: [authGuard], data: { state: "tabs_generator" } satisfies RouteDataProperties, diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html index 932816ba7a..fd278592e6 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.html +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -1,2 +1,15 @@ - - + + + + + + + + + + + {{ "passwordHistory" | i18n }} + + + + diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index 760f0627ab..3285a1c490 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -1,12 +1,28 @@ import { Component } from "@angular/core"; -import { SectionComponent } from "@bitwarden/components"; -import { UsernameGeneratorComponent } from "@bitwarden/generator-components"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ItemModule } from "@bitwarden/components"; +import { GeneratorModule } from "@bitwarden/generator-components"; + +import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +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"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [UsernameGeneratorComponent, SectionComponent], + imports: [ + GeneratorModule, + CurrentAccountComponent, + JslibModule, + PopOutComponent, + PopupHeaderComponent, + PopupPageComponent, + PopupFooterComponent, + ItemModule, + ], }) export class CredentialGeneratorComponent {} diff --git a/apps/desktop/src/app/tools/generator/credential-generator.component.ts b/apps/desktop/src/app/tools/generator/credential-generator.component.ts index 47b46c6493..36a7e7de5a 100644 --- a/apps/desktop/src/app/tools/generator/credential-generator.component.ts +++ b/apps/desktop/src/app/tools/generator/credential-generator.component.ts @@ -2,12 +2,12 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, DialogModule } from "@bitwarden/components"; -import { PasswordGeneratorComponent } from "@bitwarden/generator-components"; +import { GeneratorModule } from "@bitwarden/generator-components"; @Component({ standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [DialogModule, ButtonModule, JslibModule, PasswordGeneratorComponent], + imports: [DialogModule, ButtonModule, JslibModule, GeneratorModule], }) export class CredentialGeneratorComponent {} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 9d59ffe949..075ae5d729 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2319,7 +2319,8 @@ "message": "Unlocked" }, "generator": { - "message": "Generator" + "message": "Generator", + "description": "Short for 'credential generator'." }, "whatWouldYouLikeToGenerate": { "message": "What would you like to generate?" diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3b6eff4439..a1ea607f4b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6223,7 +6223,8 @@ "message": "Account settings" }, "generator": { - "message": "Generator" + "message": "Generator", + "description": "Short for 'credential generator'." }, "whatWouldYouLikeToGenerate": { "message": "What would you like to generate?" diff --git a/libs/tools/generator/components/readme.md b/libs/tools/generator/components/readme.md new file mode 100644 index 0000000000..395bf09c67 --- /dev/null +++ b/libs/tools/generator/components/readme.md @@ -0,0 +1,40 @@ + + +## Using generator components + +The components within this module require the following import. + +```ts +import { GeneratorModule } from "@bitwarden/generator-components"; +``` + +The credential generator provides access to all generator features. + +```html + + + + + + + + +``` + +Specialized components are provided for username and password generation. These +components support the same properties as the credential generator. + +```html + + +``` + +The emission behavior of `onGenerated` varies according to credential type. When +a credential supports immediate execution, the component automatically generates +a value and emits from `onGenerated`. An additional emission occurs each time the +user changes a setting. Users may also request a regeneration. + +When a credential does not support immediate execution, then `onGenerated` fires +only when the user clicks the "generate" button. diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 1d7ba7608d..55ddc1f810 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -10,15 +10,12 @@ import { Generators, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch } from "./util"; /** Options group for catchall emails */ @Component({ - standalone: true, selector: "tools-catchall-settings", templateUrl: "catchall-settings.component.html", - imports: [DependenciesModule], }) export class CatchallSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html new file mode 100644 index 0000000000..91a7c12210 --- /dev/null +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -0,0 +1,77 @@ + + + + {{ option.label }} + + + + +
+ +
+
+ + +
+
+ + + + +
{{ "options" | i18n }}
+
+
+ +
+ + {{ "type" | i18n }} + + {{ + credentialTypeHint$ | async + }} + +
+ + + +
+
+
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts new file mode 100644 index 0000000000..359c7505c5 --- /dev/null +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -0,0 +1,293 @@ +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + concat, + distinctUntilChanged, + filter, + map, + of, + ReplaySubject, + Subject, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { Option } from "@bitwarden/components/src/select/option"; +import { + CredentialAlgorithm, + CredentialCategory, + CredentialGeneratorInfo, + CredentialGeneratorService, + GeneratedCredential, + Generators, + isEmailAlgorithm, + isPasswordAlgorithm, + isUsernameAlgorithm, + PasswordAlgorithm, +} from "@bitwarden/generator-core"; + +/** root category that drills into username and email categories */ +const IDENTIFIER = "identifier"; +/** options available for the top-level navigation */ +type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; + +@Component({ + selector: "tools-credential-generator", + templateUrl: "credential-generator.component.html", +}) +export class CredentialGeneratorComponent implements OnInit, OnDestroy { + constructor( + private generatorService: CredentialGeneratorService, + private i18nService: I18nService, + private accountService: AccountService, + private zone: NgZone, + private formBuilder: FormBuilder, + ) {} + + /** Binds the component to a specific user's settings. When this input is not provided, + * the form binds to the active user + */ + @Input() + userId: UserId | null; + + /** Emits credentials created from a generation request. */ + @Output() + readonly onGenerated = new EventEmitter(); + + protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ + nav: null, + }); + + protected onRootChanged(nav: RootNavValue) { + // prevent subscription cycle + if (this.root$.value.nav !== nav) { + this.zone.run(() => { + this.root$.next({ nav }); + }); + } + } + + protected username = this.formBuilder.group({ + nav: [null as CredentialAlgorithm], + }); + + async ngOnInit() { + if (this.userId) { + this.userId$.next(this.userId); + } else { + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); + } + + this.generatorService + .algorithms$(["email", "username"], { userId$: this.userId$ }) + .pipe( + map((algorithms) => this.toOptions(algorithms)), + takeUntil(this.destroyed), + ) + .subscribe(this.usernameOptions$); + + this.generatorService + .algorithms$("password", { userId$: this.userId$ }) + .pipe( + map((algorithms) => { + const options = this.toOptions(algorithms) as Option[]; + options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); + return options; + }), + takeUntil(this.destroyed), + ) + .subscribe(this.rootOptions$); + + this.algorithm$ + .pipe( + map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + takeUntil(this.destroyed), + ) + .subscribe((hint) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.credentialTypeHint$.next(hint); + }); + }); + + this.algorithm$ + .pipe( + map((a) => a.category), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe((category) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.category$.next(category); + }); + }); + + // wire up the generator + this.algorithm$ + .pipe( + switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + takeUntil(this.destroyed), + ) + .subscribe((generated) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.onGenerated.next(generated); + this.value$.next(generated.credential); + }); + }); + + // assume the last-visible generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.root$ + .pipe( + filter(({ nav }) => !!nav), + switchMap((root) => { + if (root.nav === IDENTIFIER) { + return concat(of(this.username.value), this.username.valueChanges); + } else { + return of(root as { nav: PasswordAlgorithm }); + } + }), + filter(({ nav }) => !!nav), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) + .subscribe(([{ nav: algorithm }, preference]) => { + function setPreference(category: CredentialCategory) { + const p = preference[category]; + p.algorithm = algorithm; + p.updated = new Date(); + } + + // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` + if (isEmailAlgorithm(algorithm)) { + setPreference("email"); + } else if (isUsernameAlgorithm(algorithm)) { + setPreference("username"); + } else if (isPasswordAlgorithm(algorithm)) { + setPreference("password"); + } else { + return; + } + + preferences.next(preference); + }); + + // populate the form with the user's preferences to kick off interactivity + preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { + // the last preference set by the user "wins" + const userNav = email.updated > username.updated ? email : username; + const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm; + const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm; + + // update navigation; break subscription loop + this.onRootChanged(rootNav); + this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false }); + + // load algorithm metadata + const algorithm = this.generatorService.algorithm(credentialType); + + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$ + .pipe( + distinctUntilChanged((prev, next) => prev.id === next.id), + filter((a) => !a.onlyOnRequest), + takeUntil(this.destroyed), + ) + .subscribe(() => this.generate$.next()); + } + + private typeToGenerator$(type: CredentialAlgorithm) { + const dependencies = { + on$: this.generate$, + userId$: this.userId$, + }; + + switch (type) { + case "catchall": + return this.generatorService.generate$(Generators.catchall, dependencies); + + case "subaddress": + return this.generatorService.generate$(Generators.subaddress, dependencies); + + case "username": + return this.generatorService.generate$(Generators.username, dependencies); + + case "password": + return this.generatorService.generate$(Generators.password, dependencies); + + case "passphrase": + return this.generatorService.generate$(Generators.passphrase, dependencies); + + default: + throw new Error(`Invalid generator type: "${type}"`); + } + } + + /** Lists the credential types of the username algorithm box. */ + protected usernameOptions$ = new BehaviorSubject[]>([]); + + /** Lists the top-level credential types supported by the component. */ + protected rootOptions$ = new BehaviorSubject[]>([]); + + /** tracks the currently selected credential type */ + protected algorithm$ = new ReplaySubject(1); + + /** Emits hint key for the currently selected credential type */ + protected credentialTypeHint$ = new ReplaySubject(1); + + /** tracks the currently selected credential category */ + protected category$ = new ReplaySubject(1); + + /** Emits the last generated value. */ + protected readonly value$ = new BehaviorSubject(""); + + /** Emits when the userId changes */ + protected readonly userId$ = new BehaviorSubject(null); + + /** Emits when a new credential is requested */ + protected readonly generate$ = new Subject(); + + private toOptions(algorithms: CredentialGeneratorInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: algorithm.id, + label: this.i18nService.t(algorithm.nameKey), + })); + + return options; + } + + private readonly destroyed = new Subject(); + ngOnDestroy() { + this.destroyed.complete(); + + // finalize subjects + this.generate$.complete(); + this.value$.complete(); + + // finalize component bindings + this.onGenerated.complete(); + } +} diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/generator.module.ts similarity index 62% rename from libs/tools/generator/components/src/dependencies.ts rename to libs/tools/generator/components/src/generator.module.ts index 6f2c13e057..c7dfc60bab 100644 --- a/libs/tools/generator/components/src/dependencies.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -10,8 +10,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, - CheckboxModule, ColorPasswordModule, + CheckboxModule, FormFieldModule, IconButtonModule, InputModule, @@ -27,16 +27,24 @@ import { Randomizer, } from "@bitwarden/generator-core"; +import { CatchallSettingsComponent } from "./catchall-settings.component"; +import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordGeneratorComponent } from "./password-generator.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; +import { SubaddressSettingsComponent } from "./subaddress-settings.component"; +import { UsernameGeneratorComponent } from "./username-generator.component"; +import { UsernameSettingsComponent } from "./username-settings.component"; + const RANDOMIZER = new SafeInjectionToken("Randomizer"); /** Shared module containing generator component dependencies */ @NgModule({ - imports: [CardComponent, SectionComponent, SectionHeaderComponent], - exports: [ + imports: [ CardComponent, + ColorPasswordModule, CheckboxModule, CommonModule, - ColorPasswordModule, FormFieldModule, IconButtonModule, InputModule, @@ -60,8 +68,18 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); deps: [RANDOMIZER, StateProvider, PolicyService], }), ], - declarations: [], + declarations: [ + CatchallSettingsComponent, + CredentialGeneratorComponent, + SubaddressSettingsComponent, + UsernameSettingsComponent, + PasswordGeneratorComponent, + PasswordSettingsComponent, + PassphraseSettingsComponent, + UsernameGeneratorComponent, + ], + exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) -export class DependenciesModule { +export class GeneratorModule { constructor() {} } diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 9367a32546..213461174f 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1,9 +1,3 @@ -export { CatchallSettingsComponent } from "./catchall-settings.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; -export { PassphraseSettingsComponent } from "./passphrase-settings.component"; -export { PasswordSettingsComponent } from "./password-settings.component"; -export { PasswordGeneratorComponent } from "./password-generator.component"; -export { SubaddressSettingsComponent } from "./subaddress-settings.component"; -export { UsernameGeneratorComponent } from "./username-generator.component"; -export { UsernameSettingsComponent } from "./username-settings.component"; +export { GeneratorModule } from "./generator.module"; diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index bfb3425bf6..25e028210c 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -10,7 +10,6 @@ import { PassphraseGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch, toValidators } from "./util"; const Controls = Object.freeze({ @@ -22,10 +21,8 @@ const Controls = Object.freeze({ /** Options group for passphrases */ @Component({ - standalone: true, selector: "tools-passphrase-settings", templateUrl: "passphrase-settings.component.html", - imports: [DependenciesModule], }) export class PassphraseSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 62bcdfa15d..7ec3a565dd 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -5,11 +5,8 @@ (selectedChange)="onCredentialTypeChanged($event)" attr.aria-label="{{ 'type' | i18n }}" > - - {{ "password" | i18n }} - - - {{ "passphrase" | i18n }} + + {{ option.label }} @@ -24,6 +21,7 @@ type="button" bitIconButton="bwi-clone" buttonType="main" + showToast [appCopyClick]="value$ | async" > {{ "copyPassword" | i18n }} @@ -32,13 +30,13 @@ diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index b6d8fbf60d..bf33c7cfca 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,29 +1,39 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; -import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + ReplaySubject, + Subject, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { Option } from "@bitwarden/components/src/select/option"; import { CredentialGeneratorService, Generators, PasswordAlgorithm, GeneratedCredential, + CredentialGeneratorInfo, + CredentialAlgorithm, + isPasswordAlgorithm, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; -import { PassphraseSettingsComponent } from "./passphrase-settings.component"; -import { PasswordSettingsComponent } from "./password-settings.component"; - /** Options group for passwords */ @Component({ - standalone: true, selector: "tools-password-generator", templateUrl: "password-generator.component.html", - imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent], }) export class PasswordGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, ) {} @@ -36,7 +46,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { userId: UserId | null; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject("password"); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -51,9 +61,11 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { * @param type the new credential type */ protected onCredentialTypeChanged(type: PasswordAlgorithm) { + // break subscription cycle if (this.credentialType$.value !== type) { - this.credentialType$.next(type); - this.generate$.next(); + this.zone.run(() => { + this.credentialType$.next(type); + }); } } @@ -74,9 +86,18 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { .subscribe(this.userId$); } - this.credentialType$ + this.generatorService + .algorithms$("password", { userId$: this.userId$ }) .pipe( - switchMap((type) => this.typeToGenerator$(type)), + map((algorithms) => this.toOptions(algorithms)), + takeUntil(this.destroyed), + ) + .subscribe(this.passwordOptions$); + + // wire up the generator + this.algorithm$ + .pipe( + switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -87,9 +108,52 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { this.value$.next(generated.credential); }); }); + + // assume the last-visible generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.credentialType$ + .pipe( + filter((type) => !!type), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) + .subscribe(([algorithm, preference]) => { + if (isPasswordAlgorithm(algorithm)) { + preference.password.algorithm = algorithm; + preference.password.updated = new Date(); + } else { + return; + } + + preferences.next(preference); + }); + + // populate the form with the user's preferences to kick off interactivity + preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => { + // update navigation + this.onCredentialTypeChanged(password.algorithm); + + // load algorithm metadata + const algorithm = this.generatorService.algorithm(password.algorithm); + + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$ + .pipe( + distinctUntilChanged((prev, next) => prev.id === next.id), + filter((a) => !a.onlyOnRequest), + takeUntil(this.destroyed), + ) + .subscribe(() => this.generate$.next()); } - private typeToGenerator$(type: PasswordAlgorithm) { + private typeToGenerator$(type: CredentialAlgorithm) { const dependencies = { on$: this.generate$, userId$: this.userId$, @@ -106,6 +170,21 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { } } + /** Lists the credential types supported by the component. */ + protected passwordOptions$ = new BehaviorSubject[]>([]); + + /** tracks the currently selected credential type */ + protected algorithm$ = new ReplaySubject(1); + + private toOptions(algorithms: CredentialGeneratorInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: algorithm.id, + label: this.i18nService.t(algorithm.nameKey), + })); + + return options; + } + private readonly destroyed = new Subject(); ngOnDestroy(): void { // tear down subscriptions diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index d9fd6cd99c..9466c81a0f 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -10,7 +10,6 @@ import { PasswordGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch, toValidators } from "./util"; const Controls = Object.freeze({ @@ -26,10 +25,8 @@ const Controls = Object.freeze({ /** Options group for passwords */ @Component({ - standalone: true, selector: "tools-password-settings", templateUrl: "password-settings.component.html", - imports: [DependenciesModule], }) export class PasswordSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index ed55cb51ba..30db8dc657 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -10,15 +10,12 @@ import { SubaddressGenerationOptions, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch } from "./util"; /** Options group for plus-addressed emails */ @Component({ - standalone: true, selector: "tools-subaddress-settings", templateUrl: "subaddress-settings.component.html", - imports: [DependenciesModule], }) export class SubaddressSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 413de93145..a44637d78e 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -10,6 +10,7 @@ type="button" bitIconButton="bwi-clone" buttonType="main" + showToast [appCopyClick]="value$ | async" > {{ "copyPassword" | i18n }} diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index e5327cc66e..767c73c398 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -26,23 +26,10 @@ import { isUsernameAlgorithm, } from "@bitwarden/generator-core"; -import { CatchallSettingsComponent } from "./catchall-settings.component"; -import { DependenciesModule } from "./dependencies"; -import { SubaddressSettingsComponent } from "./subaddress-settings.component"; -import { UsernameSettingsComponent } from "./username-settings.component"; -import { completeOnAccountSwitch } from "./util"; - /** Component that generates usernames and emails */ @Component({ - standalone: true, selector: "tools-username-generator", templateUrl: "username-generator.component.html", - imports: [ - DependenciesModule, - CatchallSettingsComponent, - SubaddressSettingsComponent, - UsernameSettingsComponent, - ], }) export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Instantiates the username generator @@ -72,14 +59,20 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Tracks the selected generation algorithm */ protected credential = this.formBuilder.group({ - type: ["username" as CredentialAlgorithm], + type: [null as CredentialAlgorithm], }); async ngOnInit() { if (this.userId) { this.userId$.next(this.userId); } else { - this.singleUserId$().pipe(takeUntil(this.destroyed)).subscribe(this.userId$); + this.accountService.activeAccount$ + .pipe( + map((acct) => acct.id), + distinctUntilChanged(), + takeUntil(this.destroyed), + ) + .subscribe(this.userId$); } this.generatorService @@ -121,7 +114,11 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); this.credential.valueChanges - .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) + .pipe( + filter(({ type }) => !!type), + withLatestFrom(preferences), + takeUntil(this.destroyed), + ) .subscribe(([{ type }, preference]) => { if (isEmailAlgorithm(type)) { preference.email.algorithm = type; @@ -202,19 +199,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private singleUserId$() { - // FIXME: this branch should probably scan for the user and make sure - // the account is unlocked - if (this.userId) { - return new BehaviorSubject(this.userId as UserId).asObservable(); - } - - return this.accountService.activeAccount$.pipe( - completeOnAccountSwitch(), - takeUntil(this.destroyed), - ); - } - private toOptions(algorithms: CredentialGeneratorInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index 978bd05ca7..8237b8674c 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -10,15 +10,12 @@ import { Generators, } from "@bitwarden/generator-core"; -import { DependenciesModule } from "./dependencies"; import { completeOnAccountSwitch } from "./util"; /** Options group for usernames */ @Component({ - standalone: true, selector: "tools-username-settings", templateUrl: "username-settings.component.html", - imports: [DependenciesModule], }) export class UsernameSettingsComponent implements OnInit, OnDestroy { /** Instantiates the component diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts index 48b1250eb1..8adda7d1a0 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts @@ -3,10 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { - PasswordGeneratorComponent, - UsernameGeneratorComponent, -} from "@bitwarden/generator-components"; +import { GeneratorModule } from "@bitwarden/generator-components"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; @Component({ @@ -37,7 +34,7 @@ describe("CipherFormGeneratorComponent", () => { providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }) .overrideComponent(CipherFormGeneratorComponent, { - remove: { imports: [PasswordGeneratorComponent, UsernameGeneratorComponent] }, + remove: { imports: [GeneratorModule] }, add: { imports: [MockPasswordGeneratorComponent, MockUsernameGeneratorComponent] }, }) .compileComponents(); diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index ee06e601ad..db6e9ae106 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -1,11 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { SectionComponent } from "@bitwarden/components"; -import { - PasswordGeneratorComponent, - UsernameGeneratorComponent, -} from "@bitwarden/generator-components"; +import { GeneratorModule } from "@bitwarden/generator-components"; import { GeneratedCredential } from "@bitwarden/generator-core"; /** @@ -16,7 +12,7 @@ import { GeneratedCredential } from "@bitwarden/generator-core"; selector: "vault-cipher-form-generator", templateUrl: "./cipher-form-generator.component.html", standalone: true, - imports: [CommonModule, SectionComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], + imports: [CommonModule, GeneratorModule], }) export class CipherFormGeneratorComponent { /**