From 7fd592429075568b31f519fa2caeedd2e6938e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 14 Jan 2025 18:32:48 -0500 Subject: [PATCH 1/4] first pass at "generated credential" screen reader notifications --- apps/browser/src/_locales/en/messages.json | 12 +++++++ .../credential-generator-history.component.ts | 2 +- .../src/credential-generator.component.html | 1 + .../src/credential-generator.component.ts | 12 ++++++- .../src/password-generator.component.html | 1 + .../src/password-generator.component.ts | 12 ++++++- .../src/username-generator.component.html | 1 + .../src/username-generator.component.ts | 34 ++++++++++++------- .../generator/core/src/data/generators.ts | 18 ++++++---- .../credential-generator.service.spec.ts | 6 ++-- .../services/credential-generator.service.ts | 3 +- .../credential-generator-configuration.ts | 20 +++++++---- 12 files changed, 92 insertions(+), 30 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 51fb3a0a770..7db06f37d36 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/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 ce86abe80ae..91ede6f9151 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -37,6 +37,7 @@ [disabled]="!(algorithm$ | async)" > +
{{ credentialGeneratedMessage$ | async }}
!!algorithm), - map(({ generatedValue }) => generatedValue), + map(({ credentialType }) => credentialType), ); /** Emits hint key for the currently selected credential type */ @@ -491,6 +493,14 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ private readonly generate$ = new Subject(); + /** + * Emits the credential generated message whenever the generator runs + */ + protected credentialGeneratedMessage$ = this.value$.pipe( + withLatestFrom(this.algorithm$), + concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), + ); + /** Request a new value from the generator * @param requestor a label used to trace generation request * origin in the debugger. diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index a6aa5ebdd02..519846351e8 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -35,6 +35,7 @@ [disabled]="!(algorithm$ | async)" > +
{{ credentialGeneratedMessage$ | async }}
!!algorithm), - map(({ generatedValue }) => generatedValue), + map(({ credentialType }) => credentialType), + ); + + /** + * Emits the credential generated message whenever the generator runs + */ + protected credentialGeneratedMessage$ = this.value$.pipe( + withLatestFrom(this.algorithm$), + concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), ); 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 31e224713ed..58f4c379d9d 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -26,6 +26,7 @@ {{ credentialTypeCopyLabel$ | async }} +
{{ credentialGeneratedMessage$ | 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..9d053007726 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -8,9 +8,11 @@ import { catchError, combineLatest, combineLatestWith, + concatMap, distinctUntilChanged, filter, map, + of, ReplaySubject, Subject, switchMap, @@ -375,6 +377,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,20 +415,16 @@ 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(); + /** + * Emits the credential generated message whenever the generator runs + */ + protected credentialGeneratedMessage$ = this.value$.pipe( + withLatestFrom(this.algorithm$), + concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), + ); /** Request a new value from the generator * @param requestor a label used to trace generation request diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 92b20b02b75..0f957f2cecf 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: "useThisPassphrase", 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 Date: Thu, 16 Jan 2025 17:15:56 -0500 Subject: [PATCH 2/4] replace website$ dependency with `GenerateRequest` --- libs/common/src/tools/dependencies.ts | 11 +- .../src/credential-generator.component.ts | 5 +- .../src/password-generator.component.ts | 7 +- .../src/username-generator.component.ts | 5 +- .../core/src/engine/email-randomizer.ts | 24 +- .../core/src/engine/password-randomizer.ts | 36 ++- .../core/src/engine/username-randomizer.ts | 18 +- .../credential-generator.service.spec.ts | 218 ++++++------------ .../services/credential-generator.service.ts | 59 +---- .../core/src/types/credential-generator.ts | 5 +- .../core/src/types/generate-request.ts | 24 ++ .../core/src/types/generated-credential.ts | 9 +- libs/tools/generator/core/src/types/index.ts | 1 + .../options/send-options.component.ts | 7 +- 14 files changed, 186 insertions(+), 243 deletions(-) create mode 100644 libs/tools/generator/core/src/types/generate-request.ts 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.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index b8f7921253a..73c90bca011 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -30,6 +30,7 @@ import { CredentialAlgorithm, CredentialCategory, CredentialGeneratorService, + GenerateRequest, GeneratedCredential, Generators, getForwarderConfiguration, @@ -491,7 +492,7 @@ export class CredentialGeneratorComponent 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(); /** * Emits the credential generated message whenever the generator runs @@ -506,7 +507,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { * 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/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 0e6983ecc53..a3197e1d1df 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -18,7 +18,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"; @@ -30,6 +29,7 @@ import { isPasswordAlgorithm, AlgorithmInfo, isSameAlgorithm, + GenerateRequest, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; @@ -44,7 +44,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { private generatorHistoryService: GeneratorHistoryService, private toastService: ToastService, private logService: LogService, - private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, ) {} @@ -69,14 +68,14 @@ 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(); /** 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 diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 9d053007726..77d063a940e 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -30,6 +30,7 @@ import { AlgorithmInfo, CredentialAlgorithm, CredentialGeneratorService, + GenerateRequest, GeneratedCredential, Generators, getForwarderConfiguration, @@ -387,7 +388,7 @@ export class UsernameGeneratorComponent 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(); protected showAlgorithm$ = this.algorithm$.pipe( combineLatestWith(this.showForwarder$), @@ -431,7 +432,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { * 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/engine/email-randomizer.ts b/libs/tools/generator/core/src/engine/email-randomizer.ts index 722707c4d1f..0be95a975af 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.ts @@ -1,11 +1,11 @@ // 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 { CatchallGenerationOptions, CredentialGenerator, + GenerateRequest, GeneratedCredential, SubaddressGenerationOptions, } from "../types"; @@ -112,25 +112,37 @@ export class EmailRandomizer } generate( - request: GenerationRequest, + request: GenerateRequest, settings: CatchallGenerationOptions, ): Promise; 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); }, }; @@ -193,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, @@ -226,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, @@ -251,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( @@ -279,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, @@ -300,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); @@ -352,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( @@ -395,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; }, @@ -420,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; }, @@ -446,7 +387,7 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const on$ = new Subject(); + const on$ = new Subject(); const results: any[] = []; // confirm no emission during subscription @@ -457,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)]); @@ -467,7 +408,7 @@ describe("CredentialGeneratorService", () => { expect(results.length).toBe(1); // confirm forwarded emission takes latest value - on$.next(); + on$.next({}); await awaitAsync(); sub.unsubscribe(); @@ -488,7 +429,7 @@ describe("CredentialGeneratorService", () => { encryptorProvider, accountService, ); - const on$ = new Subject(); + const on$ = new Subject(); let error: any = null; // confirm no emission during subscription @@ -503,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 11a2d263516..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$; 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 636b7546af8..480ab9a4e7d 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 } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -25,7 +25,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"; @@ -116,8 +116,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({ From 061061ac78eb615424d998a3b4c9b84e1d63e636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 17 Jan 2025 13:15:32 -0500 Subject: [PATCH 3/4] pure-rx aria live implementation --- .../src/credential-generator.component.html | 4 +- .../src/credential-generator.component.ts | 42 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index bff1ab5f04a..63aa24f2054 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)" > @@ -37,7 +37,7 @@ [disabled]="!(algorithm$ | async)" > -
{{ credentialGeneratedMessage$ | async }}
+
{{ credentialGeneratedMessage$ | async }}
(); + /** The length of time to wait between 'credential generated' screen + * reader messages. + */ + @Input() + ariaAlertThrottle = DEFAULT_GENERATED_THROTTLE_MS; + protected root$ = new BehaviorSubject<{ nav: string }>({ nav: null, }); @@ -201,8 +213,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { + this.generatedCredential$.next(generated); this.onGenerated.next(generated); - this.value$.next(generated.credential); }); }); @@ -386,7 +398,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)); } @@ -485,8 +497,12 @@ 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); @@ -494,12 +510,24 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ private readonly generate$ = new Subject(); + protected readonly USER_REQUEST = "user request"; + /** * Emits the credential generated message whenever the generator runs */ - protected credentialGeneratedMessage$ = this.value$.pipe( + protected credentialGeneratedMessage$ = this.generatedCredential$.pipe( + filter((generated) => generated?.source === this.USER_REQUEST), withLatestFrom(this.algorithm$), - concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), + // throttle notifications so that repeated user requests don't spam + // the screen reader with notifications + exhaustMap(([, algorithm]) => + concat( + of(algorithm.onGeneratedMessage), + // throttle split evenly between notification and reset + of("").pipe(delay(this.ariaAlertThrottle / 2)), + of(RESET_INDICATOR).pipe(delay(this.ariaAlertThrottle / 2)), + ).pipe(takeWhile((v) => v !== RESET_INDICATOR)), + ), ); /** Request a new value from the generator @@ -526,7 +554,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // finalize subjects this.generate$.complete(); - this.value$.complete(); + this.generatedCredential$.complete(); // finalize component bindings this.onGenerated.complete(); From 70b218cc7969d38a32e30c1c5339d50fad68a147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 17 Jan 2025 15:25:57 -0500 Subject: [PATCH 4/4] switch out custom aria-live region with `LiveAnnouncer` --- .../src/credential-generator.component.html | 1 - .../src/credential-generator.component.ts | 51 ++++++------------- .../src/password-generator.component.html | 3 +- .../src/password-generator.component.ts | 27 +++++----- .../src/username-generator.component.html | 3 +- .../src/username-generator.component.ts | 25 +++++---- 6 files changed, 46 insertions(+), 64 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 63aa24f2054..624c5ab5860 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -37,7 +37,6 @@ [disabled]="!(algorithm$ | async)" > -
{{ credentialGeneratedMessage$ | async }}
(); - /** The length of time to wait between 'credential generated' screen - * reader messages. - */ - @Input() - ariaAlertThrottle = DEFAULT_GENERATED_THROTTLE_MS; - protected root$ = new BehaviorSubject<{ nav: string }>({ nav: null, }); @@ -200,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) => { @@ -213,6 +201,10 @@ 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); }); @@ -406,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$, @@ -507,28 +503,11 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** 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"; - /** - * Emits the credential generated message whenever the generator runs - */ - protected credentialGeneratedMessage$ = this.generatedCredential$.pipe( - filter((generated) => generated?.source === this.USER_REQUEST), - withLatestFrom(this.algorithm$), - // throttle notifications so that repeated user requests don't spam - // the screen reader with notifications - exhaustMap(([, algorithm]) => - concat( - of(algorithm.onGeneratedMessage), - // throttle split evenly between notification and reset - of("").pipe(delay(this.ariaAlertThrottle / 2)), - of(RESET_INDICATOR).pipe(delay(this.ariaAlertThrottle / 2)), - ).pipe(takeWhile((v) => v !== RESET_INDICATOR)), - ), - ); + /** Emits when a new credential is requested */ + private readonly generate$ = new Subject(); /** Request a new value from the generator * @param requestor a label used to trace generation request diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 519846351e8..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)" > @@ -35,7 +35,6 @@ [disabled]="!(algorithm$ | async)" > -
{{ credentialGeneratedMessage$ | async }}
(); + /** 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. @@ -138,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) => { @@ -151,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); }); @@ -206,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$, @@ -253,14 +264,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { map(({ credentialType }) => credentialType), ); - /** - * Emits the credential generated message whenever the generator runs - */ - protected credentialGeneratedMessage$ = this.value$.pipe( - withLatestFrom(this.algorithm$), - concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), - ); - private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 7aea74a388c..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)" > @@ -26,7 +26,6 @@ {{ credentialTypeCopyLabel$ | async }} -
{{ credentialGeneratedMessage$ | async }}
diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 77d063a940e..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"; @@ -8,11 +9,9 @@ import { catchError, combineLatest, combineLatestWith, - concatMap, distinctUntilChanged, filter, map, - of, ReplaySubject, Subject, switchMap, @@ -69,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, @@ -163,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) => { @@ -176,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); }); @@ -363,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[]>([]); @@ -419,13 +427,8 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { map(({ credentialType }) => credentialType), ); - /** - * Emits the credential generated message whenever the generator runs - */ - protected credentialGeneratedMessage$ = this.value$.pipe( - withLatestFrom(this.algorithm$), - concatMap(([, algorithm]) => of("", algorithm.onGeneratedMessage)), - ); + /** 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