diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c7dca5ff1f7..666dea3f5b8 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -445,6 +445,18 @@ "generatePassphrase": { "message": "Generate passphrase" }, + "passwordGenerated": { + "message": "Password generated" + }, + "passphraseGenerated": { + "message": "Passphrase generated" + }, + "usernameGenerated": { + "message": "Username generated" + }, + "emailGenerated": { + "message": "Email generated" + }, "regeneratePassword": { "message": "Regenerate password" }, diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index c22e71cff67..befe1ca5406 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,7 +1,7 @@ import { Observable } from "rxjs"; -import { Policy } from "../admin-console/models/domain/policy"; -import { OrganizationId, UserId } from "../types/guid"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; import { UserEncryptor } from "./cryptography/user-encryptor.abstraction"; @@ -152,7 +152,8 @@ export type SingleUserDependency = { }; /** A pattern for types that emit values exclusively when the dependency - * emits a message. + * emits a message. Set a type parameter when your method requires contextual + * information when the request is issued. * * Consumers of this dependency should emit when `on$` emits. If `on$` * completes, the consumer should also complete. If `on$` @@ -161,10 +162,10 @@ export type SingleUserDependency = { * @remarks This dependency is useful when you have a nondeterministic * or stateful algorithm that you would like to run when an event occurs. */ -export type OnDependency = { +export type OnDependency = { /** The stream that controls emissions */ - on$: Observable; + on$: Observable; }; /** A pattern for types that emit when a dependency is `true`. diff --git a/libs/tools/generator/components/src/credential-generator-history.component.ts b/libs/tools/generator/components/src/credential-generator-history.component.ts index 69ed0b0336d..7e476564de6 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history.component.ts @@ -72,6 +72,6 @@ export class CredentialGeneratorHistoryComponent { protected getGeneratedValueText(credential: GeneratedCredential) { const info = this.generatorService.algorithm(credential.category); - return info.generatedValue; + return info.credentialType; } } diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 6f27c7020db..624c5ab5860 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -20,7 +20,7 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate('user request')" + (click)="generate(USER_REQUEST)" [appA11yTitle]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index a2b204eaca4..04c7e5e8d87 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { @@ -28,6 +29,7 @@ import { CredentialAlgorithm, CredentialCategory, CredentialGeneratorService, + GenerateRequest, GeneratedCredential, Generators, getForwarderConfiguration, @@ -60,6 +62,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { private accountService: AccountService, private zone: NgZone, private formBuilder: FormBuilder, + private ariaLive: LiveAnnouncer, ) {} /** Binds the component to a specific user's settings. When this input is not provided, @@ -185,10 +188,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // continue with origin stream return generator; }), - withLatestFrom(this.userId$), + withLatestFrom(this.userId$, this.algorithm$), takeUntil(this.destroyed), ) - .subscribe(([generated, userId]) => { + .subscribe(([generated, userId, algorithm]) => { this.generatorHistoryService .track(userId, generated.credential, generated.category, generated.generationDate) .catch((e: unknown) => { @@ -198,8 +201,12 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { + if (generated.source === this.USER_REQUEST) { + this.announce(algorithm.onGeneratedMessage); + } + + this.generatedCredential$.next(generated); this.onGenerated.next(generated); - this.value$.next(generated.credential); }); }); @@ -383,7 +390,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { if (!a || a.onlyOnRequest) { - this.value$.next("-"); + this.generatedCredential$.next(null); } else { this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); } @@ -391,6 +398,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); } + private announce(message: string) { + this.ariaLive.announce(message).catch((e) => this.logService.error(e)); + } + private typeToGenerator$(type: CredentialAlgorithm) { const dependencies = { on$: this.generate$, @@ -473,7 +484,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ generatedValue }) => generatedValue), + map(({ credentialType }) => credentialType), ); /** Emits hint key for the currently selected credential type */ @@ -482,21 +493,28 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** tracks the currently selected credential category */ protected category$ = new ReplaySubject(1); + private readonly generatedCredential$ = new BehaviorSubject(null); + /** Emits the last generated value. */ - protected readonly value$ = new BehaviorSubject(""); + protected readonly value$ = this.generatedCredential$.pipe( + map((generated) => generated?.credential ?? "-"), + ); /** Emits when the userId changes */ protected readonly userId$ = new BehaviorSubject(null); + /** Identifies generator requests that were requested by the user */ + protected readonly USER_REQUEST = "user request"; + /** Emits when a new credential is requested */ - private readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); /** Request a new value from the generator * @param requestor a label used to trace generation request * origin in the debugger. */ protected async generate(requestor: string) { - this.generate$.next(requestor); + this.generate$.next({ source: requestor }); } private toOptions(algorithms: AlgorithmInfo[]) { @@ -515,7 +533,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // finalize subjects this.generate$.complete(); - this.value$.complete(); + this.generatedCredential$.complete(); // finalize component bindings this.onGenerated.complete(); diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index a6aa5ebdd02..c7fa93dc535 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -18,7 +18,7 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate('user request')" + (click)="generate(USER_REQUEST)" [appA11yTitle]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 85363412ffa..b59b162e687 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { @@ -16,7 +17,6 @@ import { } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { ToastService, Option } from "@bitwarden/components"; @@ -28,6 +28,7 @@ import { isPasswordAlgorithm, AlgorithmInfo, isSameAlgorithm, + GenerateRequest, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; @@ -42,9 +43,9 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { private generatorHistoryService: GeneratorHistoryService, private toastService: ToastService, private logService: LogService, - private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, + private ariaLive: LiveAnnouncer, ) {} /** Binds the component to a specific user's settings. @@ -67,14 +68,17 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { protected readonly userId$ = new BehaviorSubject(null); /** Emits when a new credential is requested */ - private readonly generate$ = new Subject(); + private readonly generate$ = new Subject(); + + /** Identifies generator requests that were requested by the user */ + protected readonly USER_REQUEST = "user request"; /** Request a new value from the generator * @param requestor a label used to trace generation request * origin in the debugger. */ protected async generate(requestor: string) { - this.generate$.next(requestor); + this.generate$.next({ source: requestor }); } /** Tracks changes to the selected credential type @@ -137,10 +141,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { // continue with origin stream return generator; }), - withLatestFrom(this.userId$), + withLatestFrom(this.userId$, this.algorithm$), takeUntil(this.destroyed), ) - .subscribe(([generated, userId]) => { + .subscribe(([generated, userId, algorithm]) => { this.generatorHistoryService .track(userId, generated.credential, generated.category, generated.generationDate) .catch((e: unknown) => { @@ -150,6 +154,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { + if (generated.source === this.USER_REQUEST) { + this.announce(algorithm.onGeneratedMessage); + } + this.onGenerated.next(generated); this.value$.next(generated.credential); }); @@ -205,6 +213,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { }); } + private announce(message: string) { + this.ariaLive.announce(message).catch((e) => this.logService.error(e)); + } + private typeToGenerator$(type: CredentialAlgorithm) { const dependencies = { on$: this.generate$, @@ -249,7 +261,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ generatedValue }) => generatedValue), + map(({ credentialType }) => credentialType), ); private toOptions(algorithms: AlgorithmInfo[]) { diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index a5effcc0f99..20cb0ec31bd 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -7,7 +7,7 @@ type="button" bitIconButton="bwi-generate" buttonType="main" - (click)="generate('user request')" + (click)="generate(USER_REQUEST)" [appA11yTitle]="credentialTypeGenerateLabel$ | async" [disabled]="!(algorithm$ | async)" > diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 63c1adc602b..e521fea8e28 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; @@ -28,6 +29,7 @@ import { AlgorithmInfo, CredentialAlgorithm, CredentialGeneratorService, + GenerateRequest, GeneratedCredential, Generators, getForwarderConfiguration, @@ -66,6 +68,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { private accountService: AccountService, private zone: NgZone, private formBuilder: FormBuilder, + private ariaLive: LiveAnnouncer, ) {} /** Binds the component to a specific user's settings. When this input is not provided, @@ -160,10 +163,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // continue with origin stream return generator; }), - withLatestFrom(this.userId$), + withLatestFrom(this.userId$, this.algorithm$), takeUntil(this.destroyed), ) - .subscribe(([generated, userId]) => { + .subscribe(([generated, userId, algorithm]) => { this.generatorHistoryService .track(userId, generated.credential, generated.category, generated.generationDate) .catch((e: unknown) => { @@ -173,6 +176,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { + if (generated.source === this.USER_REQUEST) { + this.announce(algorithm.onGeneratedMessage); + } + this.onGenerated.next(generated); this.value$.next(generated.credential); }); @@ -360,6 +367,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { throw new Error(`Invalid generator type: "${type}"`); } + private announce(message: string) { + this.ariaLive.announce(message).catch((e) => this.logService.error(e)); + } + /** Lists the credential types supported by the component. */ protected typeOptions$ = new BehaviorSubject[]>([]); @@ -375,6 +386,18 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** 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); + + /** 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 */ + private readonly generate$ = new Subject(); + protected showAlgorithm$ = this.algorithm$.pipe( combineLatestWith(this.showForwarder$), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), @@ -401,27 +424,18 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { */ protected credentialTypeLabel$ = this.algorithm$.pipe( filter((algorithm) => !!algorithm), - map(({ generatedValue }) => generatedValue), + map(({ credentialType }) => credentialType), ); - /** Emits hint key for the currently selected credential type */ - protected credentialTypeHint$ = 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 */ - private readonly generate$ = new Subject(); + /** Identifies generator requests that were requested by the user */ + protected readonly USER_REQUEST = "user request"; /** Request a new value from the generator * @param requestor a label used to trace generation request * origin in the debugger. */ protected async generate(requestor: string) { - this.generate$.next(requestor); + this.generate$.next({ source: requestor }); } private toOptions(algorithms: AlgorithmInfo[]) { diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 12209b402e0..da87c60f1f4 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -56,7 +56,8 @@ const PASSPHRASE: CredentialGeneratorConfiguration< category: "password", nameKey: "passphrase", generateKey: "generatePassphrase", - generatedValueKey: "passphrase", + onGeneratedMessageKey: "passphraseGenerated", + credentialTypeKey: "passphrase", copyKey: "copyPassphrase", useGeneratedValueKey: "useThisPassword", onlyOnRequest: false, @@ -118,7 +119,8 @@ const PASSWORD: CredentialGeneratorConfiguration< category: "password", nameKey: "password", generateKey: "generatePassword", - generatedValueKey: "password", + onGeneratedMessageKey: "passwordGenerated", + credentialTypeKey: "password", copyKey: "copyPassword", useGeneratedValueKey: "useThisPassword", onlyOnRequest: false, @@ -195,7 +197,8 @@ const USERNAME: CredentialGeneratorConfiguration; generate( - request: GenerationRequest, + request: GenerateRequest, settings: SubaddressGenerationOptions, ): Promise; async generate( - _request: GenerationRequest, + request: GenerateRequest, settings: CatchallGenerationOptions | SubaddressGenerationOptions, ) { if (isCatchallGenerationOptions(settings)) { const email = await this.randomAsciiCatchall(settings.catchallDomain); - return new GeneratedCredential(email, "catchall", Date.now()); + return new GeneratedCredential( + email, + "catchall", + Date.now(), + request.source, + request.website, + ); } else if (isSubaddressGenerationOptions(settings)) { const email = await this.randomAsciiSubaddress(settings.subaddressEmail); - return new GeneratedCredential(email, "subaddress", Date.now()); + return new GeneratedCredential( + email, + "subaddress", + Date.now(), + request.source, + request.website, + ); } throw new Error("Invalid settings received by generator."); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index c1e9aed7b8b..a9612d2fb45 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -1,10 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; -import { GenerationRequest } from "@bitwarden/common/tools/types"; import { CredentialGenerator, + GenerateRequest, GeneratedCredential, PassphraseGenerationOptions, PasswordGenerationOptions, @@ -69,27 +69,39 @@ export class PasswordRandomizer } generate( - request: GenerationRequest, + request: GenerateRequest, settings: PasswordGenerationOptions, ): Promise; generate( - request: GenerationRequest, + request: GenerateRequest, settings: PassphraseGenerationOptions, ): Promise; async generate( - _request: GenerationRequest, + request: GenerateRequest, settings: PasswordGenerationOptions | PassphraseGenerationOptions, ) { if (isPasswordGenerationOptions(settings)) { - const request = optionsToRandomAsciiRequest(settings); - const password = await this.randomAscii(request); - - return new GeneratedCredential(password, "password", Date.now()); + const req = optionsToRandomAsciiRequest(settings); + const password = await this.randomAscii(req); + + return new GeneratedCredential( + password, + "password", + Date.now(), + request.source, + request.website, + ); } else if (isPassphraseGenerationOptions(settings)) { - const request = optionsToEffWordListRequest(settings); - const passphrase = await this.randomEffLongWords(request); - - return new GeneratedCredential(passphrase, "passphrase", Date.now()); + const req = optionsToEffWordListRequest(settings); + const passphrase = await this.randomEffLongWords(req); + + return new GeneratedCredential( + passphrase, + "passphrase", + Date.now(), + request.source, + request.website, + ); } throw new Error("Invalid settings received by generator."); diff --git a/libs/tools/generator/core/src/engine/username-randomizer.ts b/libs/tools/generator/core/src/engine/username-randomizer.ts index df608553839..d13066c7e55 100644 --- a/libs/tools/generator/core/src/engine/username-randomizer.ts +++ b/libs/tools/generator/core/src/engine/username-randomizer.ts @@ -1,7 +1,11 @@ import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; -import { GenerationRequest } from "@bitwarden/common/tools/types"; -import { CredentialGenerator, EffUsernameGenerationOptions, GeneratedCredential } from "../types"; +import { + CredentialGenerator, + EffUsernameGenerationOptions, + GenerateRequest, + GeneratedCredential, +} from "../types"; import { Randomizer } from "./abstractions"; import { WordsRequest } from "./types"; @@ -51,14 +55,20 @@ export class UsernameRandomizer implements CredentialGenerator { return { generate: (request, settings) => { - const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; - const result = new GeneratedCredential(credential, SomeAlgorithm, SomeTime); + const result = new GeneratedCredential( + settings.foo, + SomeAlgorithm, + SomeTime, + request.source, + request.website, + ); return Promise.resolve(result); }, }; @@ -191,30 +201,8 @@ describe("CredentialGeneratorService", () => { }); describe("generate$", () => { - it("emits a generation for the active user when subscribed", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - stateProvider, - policyService, - apiService, - i18nService, - encryptorProvider, - accountService, - ); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); - - const result = await generated.expectEmission(); - - expect(result).toEqual(new GeneratedCredential("value", SomeAlgorithm, SomeTime)); - }); - - it("follows the active user", async () => { - const someSettings = { foo: "some value" }; - const anotherSettings = { foo: "another value" }; - await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); - await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + it("completes when `on$` completes", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const generator = new CredentialGeneratorService( randomizer, stateProvider, @@ -224,22 +212,24 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + const on$ = new Subject(); + let complete = false; - await accountService.switchAccount(AnotherUser); - await generated.pauseUntilReceived(2); - generated.unsubscribe(); + // confirm no emission during subscription + generator.generate$(SomeConfiguration, { on$ }).subscribe({ + complete: () => { + complete = true; + }, + }); + on$.complete(); + await awaitAsync(); - expect(generated.emissions).toEqual([ - new GeneratedCredential("some value", SomeAlgorithm, SomeTime), - new GeneratedCredential("another value", SomeAlgorithm, SomeTime), - ]); + expect(complete).toBeTruthy(); }); - it("emits a generation when the settings change", async () => { - const someSettings = { foo: "some value" }; - const anotherSettings = { foo: "another value" }; - await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + it("includes request.source in the generated credential", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); const generator = new CredentialGeneratorService( randomizer, stateProvider, @@ -249,23 +239,15 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); + const on$ = new BehaviorSubject({ source: "some source" }); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ })); - await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); - await generated.pauseUntilReceived(2); - generated.unsubscribe(); + const result = await generated.expectEmission(); - expect(generated.emissions).toEqual([ - new GeneratedCredential("some value", SomeAlgorithm, SomeTime), - new GeneratedCredential("another value", SomeAlgorithm, SomeTime), - ]); + expect(result.source).toEqual("some source"); }); - // FIXME: test these when the fake state provider can create the required emissions - it.todo("errors when the settings error"); - it.todo("completes when the settings complete"); - - it("includes `website$`'s last emitted value", async () => { + it("includes request.website in the generated credential", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); const generator = new CredentialGeneratorService( @@ -277,18 +259,19 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const website$ = new BehaviorSubject("some website"); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); + const on$ = new BehaviorSubject({ website: "some website" }); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ })); const result = await generated.expectEmission(); - expect(result).toEqual( - new GeneratedCredential("some website|value", SomeAlgorithm, SomeTime), - ); + expect(result.website).toEqual("some website"); }); - it("errors when `website$` errors", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); + it("uses the active user's settings", async () => { + const someSettings = { foo: "some value" }; + const anotherSettings = { foo: "another value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); const generator = new CredentialGeneratorService( randomizer, stateProvider, @@ -298,44 +281,23 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const website$ = new BehaviorSubject("some website"); - let error = null; + const on$ = new BehaviorSubject({}); + const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ })); - generator.generate$(SomeConfiguration, { website$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - website$.error({ some: "error" }); - await awaitAsync(); + await accountService.switchAccount(AnotherUser); + on$.next({}); + await generated.pauseUntilReceived(2); + generated.unsubscribe(); - expect(error).toEqual({ some: "error" }); + expect(generated.emissions).toEqual([ + new GeneratedCredential("some value", SomeAlgorithm, SomeTime), + new GeneratedCredential("another value", SomeAlgorithm, SomeTime), + ]); }); - it("completes when `website$` completes", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - stateProvider, - policyService, - apiService, - i18nService, - encryptorProvider, - accountService, - ); - const website$ = new BehaviorSubject("some website"); - let completed = false; - - generator.generate$(SomeConfiguration, { website$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - website$.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); + // FIXME: test these when the fake state provider can create the required emissions + it.todo("errors when the settings error"); + it.todo("completes when the settings complete"); it("emits a generation for a specific user when `user$` supplied", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); @@ -350,38 +312,17 @@ describe("CredentialGeneratorService", () => { accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); + const on$ = new Subject(); + const generated = new ObservableTracker( + generator.generate$(SomeConfiguration, { on$, userId$ }), + ); + on$.next({}); const result = await generated.expectEmission(); expect(result).toEqual(new GeneratedCredential("another", SomeAlgorithm, SomeTime)); }); - it("emits a generation for a specific user when `user$` emits", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService( - randomizer, - stateProvider, - policyService, - apiService, - i18nService, - encryptorProvider, - accountService, - ); - const userId = new BehaviorSubject(SomeUser); - const userId$ = userId.pipe(filter((u) => !!u)); - const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); - - userId.next(AnotherUser); - const result = await generated.pauseUntilReceived(2); - - expect(result).toEqual([ - new GeneratedCredential("value", SomeAlgorithm, SomeTime), - new GeneratedCredential("another", SomeAlgorithm, SomeTime), - ]); - }); - it("errors when `user$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); const generator = new CredentialGeneratorService( @@ -393,10 +334,11 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); + const on$ = new Subject(); const userId$ = new BehaviorSubject(SomeUser); let error = null; - generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({ error: (e: unknown) => { error = e; }, @@ -418,10 +360,11 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); + const on$ = new Subject(); const userId$ = new BehaviorSubject(SomeUser); let completed = false; - generator.generate$(SomeConfiguration, { userId$ }).subscribe({ + generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({ complete: () => { completed = true; }, @@ -444,7 +387,7 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const on$ = new Subject(); + const on$ = new Subject(); const results: any[] = []; // confirm no emission during subscription @@ -455,7 +398,7 @@ describe("CredentialGeneratorService", () => { expect(results.length).toEqual(0); // confirm forwarded emission - on$.next(); + on$.next({}); await awaitAsync(); expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]); @@ -465,7 +408,7 @@ describe("CredentialGeneratorService", () => { expect(results.length).toBe(1); // confirm forwarded emission takes latest value - on$.next(); + on$.next({}); await awaitAsync(); sub.unsubscribe(); @@ -486,7 +429,7 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const on$ = new Subject(); + const on$ = new Subject(); let error: any = null; // confirm no emission during subscription @@ -501,35 +444,8 @@ describe("CredentialGeneratorService", () => { expect(error).toEqual({ some: "error" }); }); - it("completes when `on$` completes", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - stateProvider, - policyService, - apiService, - i18nService, - encryptorProvider, - accountService, - ); - const on$ = new Subject(); - let complete = false; - - // confirm no emission during subscription - generator.generate$(SomeConfiguration, { on$ }).subscribe({ - complete: () => { - complete = true; - }, - }); - on$.complete(); - await awaitAsync(); - - expect(complete).toBeTruthy(); - }); - // FIXME: test these when the fake state provider can delay its first emission it.todo("emits when settings$ become available if on$ is called before they're ready."); - it.todo("emits when website$ become available if on$ is called before they're ready."); }); describe("algorithms", () => { diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 9659076ec0c..6e80049870c 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -2,20 +2,15 @@ // @ts-strict-ignore import { BehaviorSubject, - combineLatest, - concat, concatMap, distinctUntilChanged, endWith, filter, - first, firstValueFrom, ignoreElements, map, Observable, ReplaySubject, - share, - skipUntil, switchMap, takeUntil, withLatestFrom, @@ -34,9 +29,9 @@ import { SingleUserDependency, UserDependency, } from "@bitwarden/common/tools/dependencies"; -import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration"; +import { IntegrationMetadata } from "@bitwarden/common/tools/integration"; import { RestClient } from "@bitwarden/common/tools/integration/rpc"; -import { anyComplete } from "@bitwarden/common/tools/rx"; +import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { UserId } from "@bitwarden/common/types/guid"; @@ -57,6 +52,7 @@ import { CredentialPreference, isForwarderIntegration, ForwarderIntegration, + GenerateRequest, } from "../types"; import { CredentialGeneratorConfiguration as Configuration, @@ -69,19 +65,7 @@ import { PREFERENCES } from "./credential-preferences"; type Policy$Dependencies = UserDependency; type Settings$Dependencies = Partial; -type Generate$Dependencies = Simplify & Partial> & { - /** Emits the active website when subscribed. - * - * The generator does not respond to emissions of this interface; - * If it is provided, the generator blocks until a value becomes available. - * When `website$` is omitted, the generator uses the empty string instead. - * When `website$` completes, the generator completes. - * When `website$` errors, the generator forwards the error. - */ - website$?: Observable; - - integration$?: Observable; -}; +type Generate$Dependencies = Simplify & Partial>; type Algorithms$Dependencies = Partial; @@ -111,43 +95,20 @@ export class CredentialGeneratorService { /** Generates a stream of credentials * @param configuration determines which generator's settings are loaded - * @param dependencies.on$ when specified, a new credential is emitted when - * this emits. Otherwise, a new credential is emitted when the settings - * update. + * @param dependencies.on$ Required. A new credential is emitted when this emits. */ generate$( configuration: Readonly>, - dependencies?: Generate$Dependencies, + dependencies: Generate$Dependencies, ) { - // instantiate the engine const engine = configuration.engine.create(this.getDependencyProvider()); - - // stream blocks until all of these values are received - const website$ = dependencies?.website$ ?? new BehaviorSubject(null); - const request$ = website$.pipe(map((website) => ({ website }))); const settings$ = this.settings$(configuration, dependencies); - // if on$ triggers before settings are loaded, trigger as soon - // as they become available. - let readyOn$: Observable = null; - if (dependencies?.on$) { - const NO_EMISSIONS = {}; - const ready$ = combineLatest([settings$, request$]).pipe( - first(null, NO_EMISSIONS), - filter((value) => value !== NO_EMISSIONS), - share(), - ); - readyOn$ = concat( - dependencies.on$?.pipe(switchMap(() => ready$)), - dependencies.on$.pipe(skipUntil(ready$)), - ); - } - // generation proper - const generate$ = (readyOn$ ?? settings$).pipe( - withLatestFrom(request$, settings$), - concatMap(([, request, settings]) => engine.generate(request, settings)), - takeUntil(anyComplete([request$, settings$])), + const generate$ = dependencies.on$.pipe( + withLatestReady(settings$), + concatMap(([request, settings]) => engine.generate(request, settings)), + takeUntil(anyComplete([settings$])), ); return generate$; @@ -256,7 +217,8 @@ export class CredentialGeneratorService { category: generator.category, name: integration ? integration.name : this.i18nService.t(generator.nameKey), generate: this.i18nService.t(generator.generateKey), - generatedValue: this.i18nService.t(generator.generatedValueKey), + onGeneratedMessage: this.i18nService.t(generator.onGeneratedMessageKey), + credentialType: this.i18nService.t(generator.credentialTypeKey), copy: this.i18nService.t(generator.copyKey), useGeneratedValue: this.i18nService.t(generator.useGeneratedValueKey), onlyOnRequest: generator.onlyOnRequest, diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 0650ae5d34d..08aec48a9e7 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -34,6 +34,9 @@ export type AlgorithmInfo = { /* Localized generate button label */ generate: string; + /** Localized "credential generated" informational message */ + onGeneratedMessage: string; + /* Localized copy button label */ copy: string; @@ -41,7 +44,7 @@ export type AlgorithmInfo = { useGeneratedValue: string; /* Localized generated value label */ - generatedValue: string; + credentialType: string; /** Localized algorithm description */ description?: string; @@ -79,17 +82,22 @@ export type CredentialGeneratorInfo = { /** Localization key for the credential description*/ descriptionKey?: string; - /* Localization key for the generate command label */ + /** Localization key for the generate command label */ generateKey: string; - /* Localization key for the copy button label */ + /** Localization key for the copy button label */ copyKey: string; - /* Localized "use generated credential" button label */ + /** Localization key for the "credential generated" informational message */ + onGeneratedMessageKey: string; + + /** Localized "use generated credential" button label */ useGeneratedValueKey: string; - /* Localization key for describing values generated by this generator */ - generatedValueKey: string; + /** Localization key for describing the kind of credential generated + * by this generator. + */ + credentialTypeKey: string; /** When true, credential generation must be explicitly requested. * @remarks this property is useful when credential generation diff --git a/libs/tools/generator/core/src/types/credential-generator.ts b/libs/tools/generator/core/src/types/credential-generator.ts index c95ff25afff..c421bbbff87 100644 --- a/libs/tools/generator/core/src/types/credential-generator.ts +++ b/libs/tools/generator/core/src/types/credential-generator.ts @@ -1,5 +1,4 @@ -import { GenerationRequest } from "@bitwarden/common/tools/types"; - +import { GenerateRequest } from "./generate-request"; import { GeneratedCredential } from "./generated-credential"; /** An algorithm that generates credentials. */ @@ -8,5 +7,5 @@ export type CredentialGenerator = { * @param request runtime parameters * @param settings stored parameters */ - generate: (request: GenerationRequest, settings: Settings) => Promise; + generate: (request: GenerateRequest, settings: Settings) => Promise; }; diff --git a/libs/tools/generator/core/src/types/generate-request.ts b/libs/tools/generator/core/src/types/generate-request.ts new file mode 100644 index 00000000000..c7d5bf9c41c --- /dev/null +++ b/libs/tools/generator/core/src/types/generate-request.ts @@ -0,0 +1,24 @@ +/** Contextual information about the application state when a generator is invoked. + */ +export type GenerateRequest = { + /** Traces the origin of the generation request. This parameter is + * copied to the generated credential. + * + * @remarks This parameter it is provided solely so that generator + * consumers can differentiate request sources from one another. + * It never affects the random output of the generator algorithms, + * and it is never communicated to 3rd party systems. It MAY be + * tracked in the generator history. + */ + source?: string; + + /** Traces the website associated with a generated credential. + * + * @remarks Random generators MUST NOT depend upon the website during credential + * generation. Non-random generators MAY include the website in the generated + * credential (e.g. a catchall email address). This parameter MAY be transmitted + * to 3rd party systems (e.g. as the description for a forwarding email). + * It MAY be tracked in the generator history. + */ + website?: string; +}; diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts index 6d18a1c7892..99b864b9fd8 100644 --- a/libs/tools/generator/core/src/types/generated-credential.ts +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -11,11 +11,15 @@ export class GeneratedCredential { * @param generationDate The date that the credential was generated. * Numeric values should are interpreted using {@link Date.valueOf} * semantics. + * @param source traces the origin of the request that generated this credential. + * @param website traces the website associated with the generated credential. */ constructor( readonly credential: string, readonly category: CredentialAlgorithm, generationDate: Date | number, + readonly source?: string, + readonly website?: string, ) { if (typeof generationDate === "number") { this.generationDate = new Date(generationDate); @@ -25,7 +29,7 @@ export class GeneratedCredential { } /** The date that the credential was generated */ - generationDate: Date; + readonly generationDate: Date; /** Constructs a credential from its `toJSON` representation */ static fromJSON(jsonValue: Jsonify) { @@ -38,6 +42,9 @@ export class GeneratedCredential { /** Serializes a credential to a JSON-compatible object */ toJSON() { + // omits the source and website because they were introduced to solve + // UI bugs and it's not yet known whether there's a desire to support + // them in the generator history view. return { credential: this.credential, category: this.category, diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 48272cbf602..3e392257b0c 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -6,6 +6,7 @@ export * from "./credential-generator"; export * from "./credential-generator-configuration"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; +export * from "./generate-request"; export * from "./generator-constraints"; export * from "./generated-credential"; export * from "./generator-options"; diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index 22f1deb2fac..4b569532220 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { firstValueFrom, map, switchMap } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -27,7 +27,7 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, Generators } from "@bitwarden/generator-core"; +import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -121,8 +121,9 @@ export class SendOptionsComponent implements OnInit { } generatePassword = async () => { + const on$ = new BehaviorSubject({ source: "send" }); const generatedCredential = await firstValueFrom( - this.generatorService.generate$(Generators.password), + this.generatorService.generate$(Generators.password, { on$ }), ); this.sendOptionsForm.patchValue({