1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[PM-15200] add "generated credential" screen reader notification (#12877)

replaces website$ dependency with `GenerateRequest`
This commit is contained in:
✨ Audrey ✨
2025-01-24 14:44:42 -05:00
committed by GitHub
parent 9a5ebf94a0
commit 1fc20b55f2
21 changed files with 310 additions and 295 deletions

View File

@@ -445,6 +445,18 @@
"generatePassphrase": { "generatePassphrase": {
"message": "Generate passphrase" "message": "Generate passphrase"
}, },
"passwordGenerated": {
"message": "Password generated"
},
"passphraseGenerated": {
"message": "Passphrase generated"
},
"usernameGenerated": {
"message": "Username generated"
},
"emailGenerated": {
"message": "Email generated"
},
"regeneratePassword": { "regeneratePassword": {
"message": "Regenerate password" "message": "Regenerate password"
}, },

View File

@@ -1,7 +1,7 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { Policy } from "../admin-console/models/domain/policy"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationId, UserId } from "../types/guid"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction";
import { UserEncryptor } from "./cryptography/user-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 /** 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$` * Consumers of this dependency should emit when `on$` emits. If `on$`
* completes, the consumer should also complete. 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 * @remarks This dependency is useful when you have a nondeterministic
* or stateful algorithm that you would like to run when an event occurs. * or stateful algorithm that you would like to run when an event occurs.
*/ */
export type OnDependency = { export type OnDependency<T = any> = {
/** The stream that controls emissions /** The stream that controls emissions
*/ */
on$: Observable<any>; on$: Observable<T>;
}; };
/** A pattern for types that emit when a dependency is `true`. /** A pattern for types that emit when a dependency is `true`.

View File

@@ -72,6 +72,6 @@ export class CredentialGeneratorHistoryComponent {
protected getGeneratedValueText(credential: GeneratedCredential) { protected getGeneratedValueText(credential: GeneratedCredential) {
const info = this.generatorService.algorithm(credential.category); const info = this.generatorService.algorithm(credential.category);
return info.generatedValue; return info.credentialType;
} }
} }

View File

@@ -20,7 +20,7 @@
type="button" type="button"
bitIconButton="bwi-generate" bitIconButton="bwi-generate"
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate(USER_REQUEST)"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)" [disabled]="!(algorithm$ | async)"
> >

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
@@ -28,6 +29,7 @@ import {
CredentialAlgorithm, CredentialAlgorithm,
CredentialCategory, CredentialCategory,
CredentialGeneratorService, CredentialGeneratorService,
GenerateRequest,
GeneratedCredential, GeneratedCredential,
Generators, Generators,
getForwarderConfiguration, getForwarderConfiguration,
@@ -60,6 +62,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private ariaLive: LiveAnnouncer,
) {} ) {}
/** Binds the component to a specific user's settings. When this input is not provided, /** 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 // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$), withLatestFrom(this.userId$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId]) => { .subscribe(([generated, userId, algorithm]) => {
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(userId, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -198,8 +201,12 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
if (generated.source === this.USER_REQUEST) {
this.announce(algorithm.onGeneratedMessage);
}
this.generatedCredential$.next(generated);
this.onGenerated.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.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
this.zone.run(() => { this.zone.run(() => {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.value$.next("-"); this.generatedCredential$.next(null);
} else { } else {
this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); 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) { private typeToGenerator$(type: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
@@ -473,7 +484,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
*/ */
protected credentialTypeLabel$ = this.algorithm$.pipe( protected credentialTypeLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm), filter((algorithm) => !!algorithm),
map(({ generatedValue }) => generatedValue), map(({ credentialType }) => credentialType),
); );
/** Emits hint key for the currently selected credential type */ /** 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 */ /** tracks the currently selected credential category */
protected category$ = new ReplaySubject<string>(1); protected category$ = new ReplaySubject<string>(1);
private readonly generatedCredential$ = new BehaviorSubject<GeneratedCredential>(null);
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = this.generatedCredential$.pipe(
map((generated) => generated?.credential ?? "-"),
);
/** Emits when the userId changes */ /** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null); protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Identifies generator requests that were requested by the user */
protected readonly USER_REQUEST = "user request";
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
private readonly generate$ = new Subject<string>(); private readonly generate$ = new Subject<GenerateRequest>();
/** Request a new value from the generator /** Request a new value from the generator
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next({ source: requestor });
} }
private toOptions(algorithms: AlgorithmInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {
@@ -515,7 +533,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
// finalize subjects // finalize subjects
this.generate$.complete(); this.generate$.complete();
this.value$.complete(); this.generatedCredential$.complete();
// finalize component bindings // finalize component bindings
this.onGenerated.complete(); this.onGenerated.complete();

View File

@@ -18,7 +18,7 @@
type="button" type="button"
bitIconButton="bwi-generate" bitIconButton="bwi-generate"
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate(USER_REQUEST)"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)" [disabled]="!(algorithm$ | async)"
> >

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { import {
@@ -16,7 +17,6 @@ import {
} from "rxjs"; } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService, Option } from "@bitwarden/components"; import { ToastService, Option } from "@bitwarden/components";
@@ -28,6 +28,7 @@ import {
isPasswordAlgorithm, isPasswordAlgorithm,
AlgorithmInfo, AlgorithmInfo,
isSameAlgorithm, isSameAlgorithm,
GenerateRequest,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorHistoryService } from "@bitwarden/generator-history";
@@ -42,9 +43,9 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
private generatorHistoryService: GeneratorHistoryService, private generatorHistoryService: GeneratorHistoryService,
private toastService: ToastService, private toastService: ToastService,
private logService: LogService, private logService: LogService,
private i18nService: I18nService,
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
private ariaLive: LiveAnnouncer,
) {} ) {}
/** Binds the component to a specific user's settings. /** Binds the component to a specific user's settings.
@@ -67,14 +68,17 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
protected readonly userId$ = new BehaviorSubject<UserId>(null); protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */ /** Emits when a new credential is requested */
private readonly generate$ = new Subject<string>(); private readonly generate$ = new Subject<GenerateRequest>();
/** Identifies generator requests that were requested by the user */
protected readonly USER_REQUEST = "user request";
/** Request a new value from the generator /** Request a new value from the generator
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next({ source: requestor });
} }
/** Tracks changes to the selected credential type /** Tracks changes to the selected credential type
@@ -137,10 +141,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
// continue with origin stream // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$), withLatestFrom(this.userId$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId]) => { .subscribe(([generated, userId, algorithm]) => {
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(userId, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -150,6 +154,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
if (generated.source === this.USER_REQUEST) {
this.announce(algorithm.onGeneratedMessage);
}
this.onGenerated.next(generated); this.onGenerated.next(generated);
this.value$.next(generated.credential); 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) { private typeToGenerator$(type: CredentialAlgorithm) {
const dependencies = { const dependencies = {
on$: this.generate$, on$: this.generate$,
@@ -249,7 +261,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
*/ */
protected credentialTypeLabel$ = this.algorithm$.pipe( protected credentialTypeLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm), filter((algorithm) => !!algorithm),
map(({ generatedValue }) => generatedValue), map(({ credentialType }) => credentialType),
); );
private toOptions(algorithms: AlgorithmInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {

View File

@@ -7,7 +7,7 @@
type="button" type="button"
bitIconButton="bwi-generate" bitIconButton="bwi-generate"
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate(USER_REQUEST)"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)" [disabled]="!(algorithm$ | async)"
> >

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
@@ -28,6 +29,7 @@ import {
AlgorithmInfo, AlgorithmInfo,
CredentialAlgorithm, CredentialAlgorithm,
CredentialGeneratorService, CredentialGeneratorService,
GenerateRequest,
GeneratedCredential, GeneratedCredential,
Generators, Generators,
getForwarderConfiguration, getForwarderConfiguration,
@@ -66,6 +68,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
private accountService: AccountService, private accountService: AccountService,
private zone: NgZone, private zone: NgZone,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private ariaLive: LiveAnnouncer,
) {} ) {}
/** Binds the component to a specific user's settings. When this input is not provided, /** 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 // continue with origin stream
return generator; return generator;
}), }),
withLatestFrom(this.userId$), withLatestFrom(this.userId$, this.algorithm$),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(([generated, userId]) => { .subscribe(([generated, userId, algorithm]) => {
this.generatorHistoryService this.generatorHistoryService
.track(userId, generated.credential, generated.category, generated.generationDate) .track(userId, generated.credential, generated.category, generated.generationDate)
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -173,6 +176,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
// update subjects within the angular zone so that the // update subjects within the angular zone so that the
// template bindings refresh immediately // template bindings refresh immediately
this.zone.run(() => { this.zone.run(() => {
if (generated.source === this.USER_REQUEST) {
this.announce(algorithm.onGeneratedMessage);
}
this.onGenerated.next(generated); this.onGenerated.next(generated);
this.value$.next(generated.credential); this.value$.next(generated.credential);
}); });
@@ -360,6 +367,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
throw new Error(`Invalid generator type: "${type}"`); 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. */ /** Lists the credential types supported by the component. */
protected typeOptions$ = new BehaviorSubject<Option<string>[]>([]); protected typeOptions$ = new BehaviorSubject<Option<string>[]>([]);
@@ -375,6 +386,18 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1); protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
/** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1);
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
private readonly generate$ = new Subject<GenerateRequest>();
protected showAlgorithm$ = this.algorithm$.pipe( protected showAlgorithm$ = this.algorithm$.pipe(
combineLatestWith(this.showForwarder$), combineLatestWith(this.showForwarder$),
map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)),
@@ -401,27 +424,18 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
*/ */
protected credentialTypeLabel$ = this.algorithm$.pipe( protected credentialTypeLabel$ = this.algorithm$.pipe(
filter((algorithm) => !!algorithm), filter((algorithm) => !!algorithm),
map(({ generatedValue }) => generatedValue), map(({ credentialType }) => credentialType),
); );
/** Emits hint key for the currently selected credential type */ /** Identifies generator requests that were requested by the user */
protected credentialTypeHint$ = new ReplaySubject<string>(1); protected readonly USER_REQUEST = "user request";
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
private readonly generate$ = new Subject<string>();
/** Request a new value from the generator /** Request a new value from the generator
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected async generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next({ source: requestor });
} }
private toOptions(algorithms: AlgorithmInfo[]) { private toOptions(algorithms: AlgorithmInfo[]) {

View File

@@ -56,7 +56,8 @@ const PASSPHRASE: CredentialGeneratorConfiguration<
category: "password", category: "password",
nameKey: "passphrase", nameKey: "passphrase",
generateKey: "generatePassphrase", generateKey: "generatePassphrase",
generatedValueKey: "passphrase", onGeneratedMessageKey: "passphraseGenerated",
credentialTypeKey: "passphrase",
copyKey: "copyPassphrase", copyKey: "copyPassphrase",
useGeneratedValueKey: "useThisPassword", useGeneratedValueKey: "useThisPassword",
onlyOnRequest: false, onlyOnRequest: false,
@@ -118,7 +119,8 @@ const PASSWORD: CredentialGeneratorConfiguration<
category: "password", category: "password",
nameKey: "password", nameKey: "password",
generateKey: "generatePassword", generateKey: "generatePassword",
generatedValueKey: "password", onGeneratedMessageKey: "passwordGenerated",
credentialTypeKey: "password",
copyKey: "copyPassword", copyKey: "copyPassword",
useGeneratedValueKey: "useThisPassword", useGeneratedValueKey: "useThisPassword",
onlyOnRequest: false, onlyOnRequest: false,
@@ -195,7 +197,8 @@ const USERNAME: CredentialGeneratorConfiguration<EffUsernameGenerationOptions, N
category: "username", category: "username",
nameKey: "randomWord", nameKey: "randomWord",
generateKey: "generateUsername", generateKey: "generateUsername",
generatedValueKey: "username", onGeneratedMessageKey: "usernameGenerated",
credentialTypeKey: "username",
copyKey: "copyUsername", copyKey: "copyUsername",
useGeneratedValueKey: "useThisUsername", useGeneratedValueKey: "useThisUsername",
onlyOnRequest: false, onlyOnRequest: false,
@@ -248,7 +251,8 @@ const CATCHALL: CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPo
nameKey: "catchallEmail", nameKey: "catchallEmail",
descriptionKey: "catchallEmailDesc", descriptionKey: "catchallEmailDesc",
generateKey: "generateEmail", generateKey: "generateEmail",
generatedValueKey: "email", onGeneratedMessageKey: "emailGenerated",
credentialTypeKey: "email",
copyKey: "copyEmail", copyKey: "copyEmail",
useGeneratedValueKey: "useThisEmail", useGeneratedValueKey: "useThisEmail",
onlyOnRequest: false, onlyOnRequest: false,
@@ -304,7 +308,8 @@ const SUBADDRESS: CredentialGeneratorConfiguration<SubaddressGenerationOptions,
nameKey: "plusAddressedEmail", nameKey: "plusAddressedEmail",
descriptionKey: "plusAddressedEmailDesc", descriptionKey: "plusAddressedEmailDesc",
generateKey: "generateEmail", generateKey: "generateEmail",
generatedValueKey: "email", onGeneratedMessageKey: "emailGenerated",
credentialTypeKey: "email",
copyKey: "copyEmail", copyKey: "copyEmail",
useGeneratedValueKey: "useThisEmail", useGeneratedValueKey: "useThisEmail",
onlyOnRequest: false, onlyOnRequest: false,
@@ -362,7 +367,8 @@ export function toCredentialGeneratorConfiguration<Settings extends ApiSettings
nameKey: configuration.name, nameKey: configuration.name,
descriptionKey: "forwardedEmailDesc", descriptionKey: "forwardedEmailDesc",
generateKey: "generateEmail", generateKey: "generateEmail",
generatedValueKey: "email", onGeneratedMessageKey: "emailGenerated",
credentialTypeKey: "email",
copyKey: "copyEmail", copyKey: "copyEmail",
useGeneratedValueKey: "useThisEmail", useGeneratedValueKey: "useThisEmail",
onlyOnRequest: true, onlyOnRequest: true,

View File

@@ -1,11 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { GenerationRequest } from "@bitwarden/common/tools/types";
import { import {
CatchallGenerationOptions, CatchallGenerationOptions,
CredentialGenerator, CredentialGenerator,
GenerateRequest,
GeneratedCredential, GeneratedCredential,
SubaddressGenerationOptions, SubaddressGenerationOptions,
} from "../types"; } from "../types";
@@ -112,25 +112,37 @@ export class EmailRandomizer
} }
generate( generate(
request: GenerationRequest, request: GenerateRequest,
settings: CatchallGenerationOptions, settings: CatchallGenerationOptions,
): Promise<GeneratedCredential>; ): Promise<GeneratedCredential>;
generate( generate(
request: GenerationRequest, request: GenerateRequest,
settings: SubaddressGenerationOptions, settings: SubaddressGenerationOptions,
): Promise<GeneratedCredential>; ): Promise<GeneratedCredential>;
async generate( async generate(
_request: GenerationRequest, request: GenerateRequest,
settings: CatchallGenerationOptions | SubaddressGenerationOptions, settings: CatchallGenerationOptions | SubaddressGenerationOptions,
) { ) {
if (isCatchallGenerationOptions(settings)) { if (isCatchallGenerationOptions(settings)) {
const email = await this.randomAsciiCatchall(settings.catchallDomain); 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)) { } else if (isSubaddressGenerationOptions(settings)) {
const email = await this.randomAsciiSubaddress(settings.subaddressEmail); 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."); throw new Error("Invalid settings received by generator.");

View File

@@ -1,10 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { GenerationRequest } from "@bitwarden/common/tools/types";
import { import {
CredentialGenerator, CredentialGenerator,
GenerateRequest,
GeneratedCredential, GeneratedCredential,
PassphraseGenerationOptions, PassphraseGenerationOptions,
PasswordGenerationOptions, PasswordGenerationOptions,
@@ -69,27 +69,39 @@ export class PasswordRandomizer
} }
generate( generate(
request: GenerationRequest, request: GenerateRequest,
settings: PasswordGenerationOptions, settings: PasswordGenerationOptions,
): Promise<GeneratedCredential>; ): Promise<GeneratedCredential>;
generate( generate(
request: GenerationRequest, request: GenerateRequest,
settings: PassphraseGenerationOptions, settings: PassphraseGenerationOptions,
): Promise<GeneratedCredential>; ): Promise<GeneratedCredential>;
async generate( async generate(
_request: GenerationRequest, request: GenerateRequest,
settings: PasswordGenerationOptions | PassphraseGenerationOptions, settings: PasswordGenerationOptions | PassphraseGenerationOptions,
) { ) {
if (isPasswordGenerationOptions(settings)) { if (isPasswordGenerationOptions(settings)) {
const request = optionsToRandomAsciiRequest(settings); const req = optionsToRandomAsciiRequest(settings);
const password = await this.randomAscii(request); const password = await this.randomAscii(req);
return new GeneratedCredential(password, "password", Date.now()); return new GeneratedCredential(
password,
"password",
Date.now(),
request.source,
request.website,
);
} else if (isPassphraseGenerationOptions(settings)) { } else if (isPassphraseGenerationOptions(settings)) {
const request = optionsToEffWordListRequest(settings); const req = optionsToEffWordListRequest(settings);
const passphrase = await this.randomEffLongWords(request); const passphrase = await this.randomEffLongWords(req);
return new GeneratedCredential(passphrase, "passphrase", Date.now()); return new GeneratedCredential(
passphrase,
"passphrase",
Date.now(),
request.source,
request.website,
);
} }
throw new Error("Invalid settings received by generator."); throw new Error("Invalid settings received by generator.");

View File

@@ -1,7 +1,11 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; 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 { Randomizer } from "./abstractions";
import { WordsRequest } from "./types"; import { WordsRequest } from "./types";
@@ -51,14 +55,20 @@ export class UsernameRandomizer implements CredentialGenerator<EffUsernameGenera
return result; return result;
} }
async generate(_request: GenerationRequest, settings: EffUsernameGenerationOptions) { async generate(request: GenerateRequest, settings: EffUsernameGenerationOptions) {
if (isEffUsernameGenerationOptions(settings)) { if (isEffUsernameGenerationOptions(settings)) {
const username = await this.randomWords({ const username = await this.randomWords({
digits: settings.wordIncludeNumber ? NUMBER_OF_DIGITS : 0, digits: settings.wordIncludeNumber ? NUMBER_OF_DIGITS : 0,
casing: settings.wordCapitalize ? "TitleCase" : "lowercase", casing: settings.wordCapitalize ? "TitleCase" : "lowercase",
}); });
return new GeneratedCredential(username, "username", Date.now()); return new GeneratedCredential(
username,
"username",
Date.now(),
request.source,
request.website,
);
} }
throw new Error("Invalid settings received by generator."); throw new Error("Invalid settings received by generator.");

View File

@@ -1,5 +1,7 @@
// FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support
// @ts-strict-ignore
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -23,6 +25,7 @@ import { Generators } from "../data";
import { import {
CredentialGeneratorConfiguration, CredentialGeneratorConfiguration,
GeneratedCredential, GeneratedCredential,
GenerateRequest,
GeneratorConstraints, GeneratorConstraints,
} from "../types"; } from "../types";
@@ -72,7 +75,8 @@ const SomeAlgorithm = "passphrase";
const SomeCategory = "password"; const SomeCategory = "password";
const SomeNameKey = "passphraseKey"; const SomeNameKey = "passphraseKey";
const SomeGenerateKey = "generateKey"; const SomeGenerateKey = "generateKey";
const SomeGeneratedValueKey = "generatedValueKey"; const SomeCredentialTypeKey = "credentialTypeKey";
const SomeOnGeneratedMessageKey = "onGeneratedMessageKey";
const SomeCopyKey = "copyKey"; const SomeCopyKey = "copyKey";
const SomeUseGeneratedValueKey = "useGeneratedValueKey"; const SomeUseGeneratedValueKey = "useGeneratedValueKey";
@@ -82,7 +86,8 @@ const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePoli
category: SomeCategory, category: SomeCategory,
nameKey: SomeNameKey, nameKey: SomeNameKey,
generateKey: SomeGenerateKey, generateKey: SomeGenerateKey,
generatedValueKey: SomeGeneratedValueKey, onGeneratedMessageKey: SomeOnGeneratedMessageKey,
credentialTypeKey: SomeCredentialTypeKey,
copyKey: SomeCopyKey, copyKey: SomeCopyKey,
useGeneratedValueKey: SomeUseGeneratedValueKey, useGeneratedValueKey: SomeUseGeneratedValueKey,
onlyOnRequest: false, onlyOnRequest: false,
@@ -91,8 +96,13 @@ const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePoli
create: (_randomizer) => { create: (_randomizer) => {
return { return {
generate: (request, settings) => { generate: (request, settings) => {
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; const result = new GeneratedCredential(
const result = new GeneratedCredential(credential, SomeAlgorithm, SomeTime); settings.foo,
SomeAlgorithm,
SomeTime,
request.source,
request.website,
);
return Promise.resolve(result); return Promise.resolve(result);
}, },
}; };
@@ -191,7 +201,33 @@ describe("CredentialGeneratorService", () => {
}); });
describe("generate$", () => { describe("generate$", () => {
it("emits a generation for the active user when subscribed", async () => { 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<GenerateRequest>();
let complete = false;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
complete: () => {
complete = true;
},
});
on$.complete();
await awaitAsync();
expect(complete).toBeTruthy();
});
it("includes request.source in the generated credential", async () => {
const settings = { foo: "value" }; const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser); await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
@@ -203,14 +239,35 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const on$ = new BehaviorSubject<GenerateRequest>({ source: "some source" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
const result = await generated.expectEmission(); const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("value", SomeAlgorithm, SomeTime)); expect(result.source).toEqual("some source");
}); });
it("follows the active user", async () => { it("includes request.website in the generated credential", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const on$ = new BehaviorSubject({ website: "some website" });
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
const result = await generated.expectEmission();
expect(result.website).toEqual("some website");
});
it("uses the active user's settings", async () => {
const someSettings = { foo: "some value" }; const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" }; const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
@@ -224,34 +281,11 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const on$ = new BehaviorSubject({});
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { on$ }));
await accountService.switchAccount(AnotherUser); await accountService.switchAccount(AnotherUser);
await generated.pauseUntilReceived(2); on$.next({});
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeAlgorithm, SomeTime),
new GeneratedCredential("another value", SomeAlgorithm, SomeTime),
]);
});
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);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
await generated.pauseUntilReceived(2); await generated.pauseUntilReceived(2);
generated.unsubscribe(); generated.unsubscribe();
@@ -265,78 +299,6 @@ describe("CredentialGeneratorService", () => {
it.todo("errors when the settings error"); it.todo("errors when the settings error");
it.todo("completes when the settings complete"); it.todo("completes when the settings complete");
it("includes `website$`'s last emitted value", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
const result = await generated.expectEmission();
expect(result).toEqual(
new GeneratedCredential("some website|value", SomeAlgorithm, SomeTime),
);
});
it("errors when `website$` errors", 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 error = null;
generator.generate$(SomeConfiguration, { website$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
website$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
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();
});
it("emits a generation for a specific user when `user$` supplied", async () => { it("emits a generation for a specific user when `user$` supplied", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
@@ -350,38 +312,17 @@ describe("CredentialGeneratorService", () => {
accountService, accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); const on$ = new Subject<GenerateRequest>();
const generated = new ObservableTracker(
generator.generate$(SomeConfiguration, { on$, userId$ }),
);
on$.next({});
const result = await generated.expectEmission(); const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("another", SomeAlgorithm, SomeTime)); 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 () => { it("errors when `user$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser); await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService( const generator = new CredentialGeneratorService(
@@ -393,10 +334,11 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let error = null; let error = null;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({
error: (e: unknown) => { error: (e: unknown) => {
error = e; error = e;
}, },
@@ -418,10 +360,11 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const on$ = new Subject<GenerateRequest>();
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let completed = false; let completed = false;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({ generator.generate$(SomeConfiguration, { on$, userId$ }).subscribe({
complete: () => { complete: () => {
completed = true; completed = true;
}, },
@@ -444,7 +387,7 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const on$ = new Subject<void>(); const on$ = new Subject<GenerateRequest>();
const results: any[] = []; const results: any[] = [];
// confirm no emission during subscription // confirm no emission during subscription
@@ -455,7 +398,7 @@ describe("CredentialGeneratorService", () => {
expect(results.length).toEqual(0); expect(results.length).toEqual(0);
// confirm forwarded emission // confirm forwarded emission
on$.next(); on$.next({});
await awaitAsync(); await awaitAsync();
expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]); expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]);
@@ -465,7 +408,7 @@ describe("CredentialGeneratorService", () => {
expect(results.length).toBe(1); expect(results.length).toBe(1);
// confirm forwarded emission takes latest value // confirm forwarded emission takes latest value
on$.next(); on$.next({});
await awaitAsync(); await awaitAsync();
sub.unsubscribe(); sub.unsubscribe();
@@ -486,7 +429,7 @@ describe("CredentialGeneratorService", () => {
encryptorProvider, encryptorProvider,
accountService, accountService,
); );
const on$ = new Subject<void>(); const on$ = new Subject<GenerateRequest>();
let error: any = null; let error: any = null;
// confirm no emission during subscription // confirm no emission during subscription
@@ -501,35 +444,8 @@ describe("CredentialGeneratorService", () => {
expect(error).toEqual({ some: "error" }); 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<void>();
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 // 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 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", () => { describe("algorithms", () => {

View File

@@ -2,20 +2,15 @@
// @ts-strict-ignore // @ts-strict-ignore
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest,
concat,
concatMap, concatMap,
distinctUntilChanged, distinctUntilChanged,
endWith, endWith,
filter, filter,
first,
firstValueFrom, firstValueFrom,
ignoreElements, ignoreElements,
map, map,
Observable, Observable,
ReplaySubject, ReplaySubject,
share,
skipUntil,
switchMap, switchMap,
takeUntil, takeUntil,
withLatestFrom, withLatestFrom,
@@ -34,9 +29,9 @@ import {
SingleUserDependency, SingleUserDependency,
UserDependency, UserDependency,
} from "@bitwarden/common/tools/dependencies"; } 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 { 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 { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@@ -57,6 +52,7 @@ import {
CredentialPreference, CredentialPreference,
isForwarderIntegration, isForwarderIntegration,
ForwarderIntegration, ForwarderIntegration,
GenerateRequest,
} from "../types"; } from "../types";
import { import {
CredentialGeneratorConfiguration as Configuration, CredentialGeneratorConfiguration as Configuration,
@@ -69,19 +65,7 @@ import { PREFERENCES } from "./credential-preferences";
type Policy$Dependencies = UserDependency; type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>; type Settings$Dependencies = Partial<UserDependency>;
type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & { type Generate$Dependencies = Simplify<OnDependency<GenerateRequest> & Partial<UserDependency>>;
/** 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<string>;
integration$?: Observable<IntegrationId>;
};
type Algorithms$Dependencies = Partial<UserDependency>; type Algorithms$Dependencies = Partial<UserDependency>;
@@ -111,43 +95,20 @@ export class CredentialGeneratorService {
/** Generates a stream of credentials /** Generates a stream of credentials
* @param configuration determines which generator's settings are loaded * @param configuration determines which generator's settings are loaded
* @param dependencies.on$ when specified, a new credential is emitted when * @param dependencies.on$ Required. A new credential is emitted when this emits.
* this emits. Otherwise, a new credential is emitted when the settings
* update.
*/ */
generate$<Settings extends object, Policy>( generate$<Settings extends object, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>, configuration: Readonly<Configuration<Settings, Policy>>,
dependencies?: Generate$Dependencies, dependencies: Generate$Dependencies,
) { ) {
// instantiate the engine
const engine = configuration.engine.create(this.getDependencyProvider()); const engine = configuration.engine.create(this.getDependencyProvider());
// stream blocks until all of these values are received
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
const request$ = website$.pipe(map((website) => ({ website })));
const settings$ = this.settings$(configuration, dependencies); const settings$ = this.settings$(configuration, dependencies);
// if on$ triggers before settings are loaded, trigger as soon
// as they become available.
let readyOn$: Observable<any> = 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 // generation proper
const generate$ = (readyOn$ ?? settings$).pipe( const generate$ = dependencies.on$.pipe(
withLatestFrom(request$, settings$), withLatestReady(settings$),
concatMap(([, request, settings]) => engine.generate(request, settings)), concatMap(([request, settings]) => engine.generate(request, settings)),
takeUntil(anyComplete([request$, settings$])), takeUntil(anyComplete([settings$])),
); );
return generate$; return generate$;
@@ -256,7 +217,8 @@ export class CredentialGeneratorService {
category: generator.category, category: generator.category,
name: integration ? integration.name : this.i18nService.t(generator.nameKey), name: integration ? integration.name : this.i18nService.t(generator.nameKey),
generate: this.i18nService.t(generator.generateKey), 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), copy: this.i18nService.t(generator.copyKey),
useGeneratedValue: this.i18nService.t(generator.useGeneratedValueKey), useGeneratedValue: this.i18nService.t(generator.useGeneratedValueKey),
onlyOnRequest: generator.onlyOnRequest, onlyOnRequest: generator.onlyOnRequest,

View File

@@ -34,6 +34,9 @@ export type AlgorithmInfo = {
/* Localized generate button label */ /* Localized generate button label */
generate: string; generate: string;
/** Localized "credential generated" informational message */
onGeneratedMessage: string;
/* Localized copy button label */ /* Localized copy button label */
copy: string; copy: string;
@@ -41,7 +44,7 @@ export type AlgorithmInfo = {
useGeneratedValue: string; useGeneratedValue: string;
/* Localized generated value label */ /* Localized generated value label */
generatedValue: string; credentialType: string;
/** Localized algorithm description */ /** Localized algorithm description */
description?: string; description?: string;
@@ -79,17 +82,22 @@ export type CredentialGeneratorInfo = {
/** Localization key for the credential description*/ /** Localization key for the credential description*/
descriptionKey?: string; descriptionKey?: string;
/* Localization key for the generate command label */ /** Localization key for the generate command label */
generateKey: string; generateKey: string;
/* Localization key for the copy button label */ /** Localization key for the copy button label */
copyKey: string; 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; useGeneratedValueKey: string;
/* Localization key for describing values generated by this generator */ /** Localization key for describing the kind of credential generated
generatedValueKey: string; * by this generator.
*/
credentialTypeKey: string;
/** When true, credential generation must be explicitly requested. /** When true, credential generation must be explicitly requested.
* @remarks this property is useful when credential generation * @remarks this property is useful when credential generation

View File

@@ -1,5 +1,4 @@
import { GenerationRequest } from "@bitwarden/common/tools/types"; import { GenerateRequest } from "./generate-request";
import { GeneratedCredential } from "./generated-credential"; import { GeneratedCredential } from "./generated-credential";
/** An algorithm that generates credentials. */ /** An algorithm that generates credentials. */
@@ -8,5 +7,5 @@ export type CredentialGenerator<Settings> = {
* @param request runtime parameters * @param request runtime parameters
* @param settings stored parameters * @param settings stored parameters
*/ */
generate: (request: GenerationRequest, settings: Settings) => Promise<GeneratedCredential>; generate: (request: GenerateRequest, settings: Settings) => Promise<GeneratedCredential>;
}; };

View File

@@ -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;
};

View File

@@ -11,11 +11,15 @@ export class GeneratedCredential {
* @param generationDate The date that the credential was generated. * @param generationDate The date that the credential was generated.
* Numeric values should are interpreted using {@link Date.valueOf} * Numeric values should are interpreted using {@link Date.valueOf}
* semantics. * semantics.
* @param source traces the origin of the request that generated this credential.
* @param website traces the website associated with the generated credential.
*/ */
constructor( constructor(
readonly credential: string, readonly credential: string,
readonly category: CredentialAlgorithm, readonly category: CredentialAlgorithm,
generationDate: Date | number, generationDate: Date | number,
readonly source?: string,
readonly website?: string,
) { ) {
if (typeof generationDate === "number") { if (typeof generationDate === "number") {
this.generationDate = new Date(generationDate); this.generationDate = new Date(generationDate);
@@ -25,7 +29,7 @@ export class GeneratedCredential {
} }
/** The date that the credential was generated */ /** The date that the credential was generated */
generationDate: Date; readonly generationDate: Date;
/** Constructs a credential from its `toJSON` representation */ /** Constructs a credential from its `toJSON` representation */
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) { static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
@@ -38,6 +42,9 @@ export class GeneratedCredential {
/** Serializes a credential to a JSON-compatible object */ /** Serializes a credential to a JSON-compatible object */
toJSON() { 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 { return {
credential: this.credential, credential: this.credential,
category: this.category, category: this.category,

View File

@@ -6,6 +6,7 @@ export * from "./credential-generator";
export * from "./credential-generator-configuration"; export * from "./credential-generator-configuration";
export * from "./eff-username-generator-options"; export * from "./eff-username-generator-options";
export * from "./forwarder-options"; export * from "./forwarder-options";
export * from "./generate-request";
export * from "./generator-constraints"; export * from "./generator-constraints";
export * from "./generated-credential"; export * from "./generated-credential";
export * from "./generator-options"; export * from "./generator-options";

View File

@@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; 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 { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -27,7 +27,7 @@ import {
ToastService, ToastService,
TypographyModule, TypographyModule,
} from "@bitwarden/components"; } 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 { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container"; import { SendFormContainer } from "../../send-form-container";
@@ -121,8 +121,9 @@ export class SendOptionsComponent implements OnInit {
} }
generatePassword = async () => { generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send" });
const generatedCredential = await firstValueFrom( const generatedCredential = await firstValueFrom(
this.generatorService.generate$(Generators.password), this.generatorService.generate$(Generators.password, { on$ }),
); );
this.sendOptionsForm.patchValue({ this.sendOptionsForm.patchValue({