From 98fef39f64a422057f971f1aed411adcc2864a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 29 Oct 2024 11:22:43 -0400 Subject: [PATCH 01/17] rough-in passphrase validation failure handling --- .../src/credential-generator.component.html | 1 + .../src/credential-generator.component.ts | 24 ++++++- .../src/passphrase-settings.component.html | 16 ++++- .../src/passphrase-settings.component.ts | 69 ++++++++++++++++++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 737e32fa1f9..80043373366 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -42,6 +42,7 @@ (onUpdated)="generate('password settings')" /> this.logService.error(e)); } }); }); @@ -487,7 +501,11 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { + if (this.passphraseSettings) { + await this.passphraseSettings.reloadSettings("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index 25e9684e864..f243f1ef879 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -7,7 +7,13 @@
{{ "options" | i18n }}
{{ "numWords" | i18n }} - + @@ -15,7 +21,13 @@
{{ "options" | i18n }}
{{ "wordSeparator" | i18n }} - + diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 4c171e0c205..0ea9c95e270 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,7 +1,19 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, takeUntil, Subject } from "rxjs"; +import { + BehaviorSubject, + skip, + takeUntil, + Subject, + combineLatest, + filter, + map, + withLatestFrom, + Observable, + merge, + firstValueFrom, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -69,6 +81,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { async ngOnInit() { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.passphrase, { singleUserId$ }); + settings.pipe(takeUntil(this.destroyed$)).subscribe(this.okSettings$); // skips reactive event emissions to break a subscription cycle settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { @@ -100,12 +113,63 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { }); // now that outputs are set up, connect inputs - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.settings$().pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + protected settings$(): Observable> { + // save valid changes + const validChanges$ = combineLatest([ + this.settings.valueChanges, + this.settings.statusChanges, + ]).pipe( + filter(([, status]) => status === "VALID"), + map(([settings]) => settings), + ); + + // discards changes but keep the override setting that changed + const overrides = [Controls.capitalize, Controls.includeNumber]; + const overrideChanges$ = this.settings.valueChanges.pipe( + withLatestFrom(this.okSettings$), + filter(([current, ok]) => overrides.some((c) => current[c] !== ok[c])), + map(([current, ok]) => { + const copy = { ...ok }; + for (const override of overrides) { + copy[override] = current[override]; + } + return copy; + }), + ); + + // save reloaded settings when requested + const reloadChanges$ = this.reloadSettings$.pipe( + withLatestFrom(this.okSettings$), + map(([, settings]) => settings), + ); + + return merge(validChanges$, overrideChanges$, reloadChanges$); } /** display binding for enterprise policy notice */ protected policyInEffect: boolean; + private okSettings$ = new BehaviorSubject(null); + + private reloadSettings$ = new Subject(); + + /** triggers a reload of the users' settings + * @param site labels the invocation site so that an operation + * can be traced back to its origin. Useful for debugging rxjs. + * @returns a promise that completes once a reload occurs. + */ + async reloadSettings(site: string = "component api call") { + const reloadComplete = firstValueFrom(this.okSettings$); + if (this.settings.invalid) { + this.reloadSettings$.next(site); + } + + await reloadComplete; + } + private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { this.settings.get(setting).enable({ emitEvent: false }); @@ -129,6 +193,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } From 1ad143f3469ac2d33eca07eb2176c5ed832480a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 30 Oct 2024 11:49:24 -0400 Subject: [PATCH 02/17] trigger valid change from settings --- .../src/passphrase-settings.component.html | 4 +-- .../src/passphrase-settings.component.ts | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index f243f1ef879..6ee1f204894 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -12,7 +12,7 @@
{{ "options" | i18n }}
formControlName="numWords" id="num-words" type="number" - (blur)="reloadSettings('numWords')" + (focusout)="reloadSettings('numWords')" />
@@ -26,7 +26,7 @@
{{ "options" | i18n }}
formControlName="wordSeparator" id="word-separator" type="text" - (blur)="reloadSettings('wordSeparator')" + (focusout)="reloadSettings('wordSeparator')" /> diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 0ea9c95e270..d6406a7750c 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -6,13 +6,13 @@ import { skip, takeUntil, Subject, - combineLatest, filter, map, withLatestFrom, Observable, merge, firstValueFrom, + ReplaySubject, } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -81,7 +81,12 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { async ngOnInit() { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.passphrase, { singleUserId$ }); - settings.pipe(takeUntil(this.destroyed$)).subscribe(this.okSettings$); + settings + .pipe( + filter((s) => !!s), + takeUntil(this.destroyed$), + ) + .subscribe(this.okSettings$); // skips reactive event emissions to break a subscription cycle settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { @@ -118,19 +123,18 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { protected settings$(): Observable> { // save valid changes - const validChanges$ = combineLatest([ - this.settings.valueChanges, - this.settings.statusChanges, - ]).pipe( - filter(([, status]) => status === "VALID"), - map(([settings]) => settings), + const validChanges$ = this.settings.statusChanges.pipe( + filter((status) => status === "VALID"), + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), ); // discards changes but keep the override setting that changed const overrides = [Controls.capitalize, Controls.includeNumber]; const overrideChanges$ = this.settings.valueChanges.pipe( + filter((settings) => !!settings), withLatestFrom(this.okSettings$), - filter(([current, ok]) => overrides.some((c) => current[c] !== ok[c])), + filter(([current, ok]) => overrides.some((c) => (current[c] ?? ok[c]) !== ok[c])), map(([current, ok]) => { const copy = { ...ok }; for (const override of overrides) { @@ -152,7 +156,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { /** display binding for enterprise policy notice */ protected policyInEffect: boolean; - private okSettings$ = new BehaviorSubject(null); + private okSettings$ = new ReplaySubject(1); private reloadSettings$ = new Subject(); @@ -165,9 +169,8 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { const reloadComplete = firstValueFrom(this.okSettings$); if (this.settings.invalid) { this.reloadSettings$.next(site); + await reloadComplete; } - - await reloadComplete; } private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { From 3ee034ad7ce61cfe1badfaccd000d5fe6e4d4828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 30 Oct 2024 15:20:07 -0400 Subject: [PATCH 03/17] fix `max` constraint enforcement --- libs/tools/generator/components/src/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index d6cd4e6fbaf..7977f774594 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -49,7 +49,7 @@ export function toValidators( } const max = getConstraint("max", config, runtime); - if (max === undefined) { + if (max !== undefined) { validators.push(Validators.max(max)); } From 42be5db4ca6e87735c88fb54ae49e5c47c2638d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 31 Oct 2024 11:06:42 -0400 Subject: [PATCH 04/17] add taps for generator validation monitoring/debugging --- .../components/src/passphrase-settings.component.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 69421d14b38..1c5315f3b42 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -13,6 +13,7 @@ import { merge, firstValueFrom, ReplaySubject, + tap, } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -87,6 +88,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { settings .pipe( filter((s) => !!s), + tap((value) => console.log(`update ok settings: ${JSON.stringify(value)}`)), takeUntil(this.destroyed$), ) .subscribe(this.okSettings$); @@ -137,6 +139,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { filter((status) => status === "VALID"), withLatestFrom(this.settings.valueChanges), map(([, settings]) => settings), + tap((value) => console.log(`valid change: ${JSON.stringify(value)}`)) ); // discards changes but keep the override setting that changed @@ -152,12 +155,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { } return copy; }), + tap((value) => console.log(`override: ${JSON.stringify(value)}`)) ); // save reloaded settings when requested const reloadChanges$ = this.reloadSettings$.pipe( withLatestFrom(this.okSettings$), map(([, settings]) => settings), + tap((value) => console.log(`reload: ${JSON.stringify(value)}`)) ); return merge(validChanges$, overrideChanges$, reloadChanges$); From 6832b4353ee11d1016f7c745fef05f9c82284fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 31 Oct 2024 16:18:47 -0400 Subject: [PATCH 05/17] HTML constraints validation rises like a phoenix --- .../src/credential-generator.component.ts | 2 +- .../src/passphrase-settings.component.html | 13 ++- .../src/passphrase-settings.component.ts | 84 ++++++------------- 3 files changed, 34 insertions(+), 65 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 26d1cb35f98..b81c1518ff6 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -511,7 +511,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { */ protected async generate(requestor: string) { if (this.passphraseSettings) { - await this.passphraseSettings.reloadSettings("credential generator"); + await this.passphraseSettings.save("credential generator"); } this.generate$.next(requestor); diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index e6a6815a9f5..e479a437a87 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -12,7 +12,9 @@
{{ "options" | i18n }}
formControlName="numWords" id="num-words" type="number" - (focusout)="reloadSettings('numWords')" + [min]="minNumWords" + [max]="maxNumWords" + (change)="save('numWords')" /> {{ numWordsBoundariesHint$ | async }} @@ -27,15 +29,18 @@
{{ "options" | i18n }}
formControlName="wordSeparator" id="word-separator" type="text" - (focusout)="reloadSettings('wordSeparator')" + [maxlength]="wordSeparatorMaxLength" + (change)="save('wordSeparator')" /> - + {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }}

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 1c5315f3b42..4d6e9471fb4 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -9,9 +9,6 @@ import { filter, map, withLatestFrom, - Observable, - merge, - firstValueFrom, ReplaySubject, tap, } from "rxjs"; @@ -25,7 +22,7 @@ import { PassphraseGenerationOptions, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ numWords: "numWords", @@ -106,16 +103,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { .policy$(Generators.passphrase, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.settings - .get(Controls.numWords) - .setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints)); - - this.settings - .get(Controls.wordSeparator) - .setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints)); - - this.settings.updateValueAndValidity({ emitEvent: false }); - + // reactive form validation doesn't work well with the generator's + // "auto-fix invalid data" feature. HTML constraints are used to + // improve usability. This approach causes `valueChanges` to fire + // *every time* this subscription fires. Take care not to leak these + // false emissions from the `onUpdated` event. + this.minNumWords = constraints.numWords.min; + this.maxNumWords = constraints.numWords.max; + this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; this.policyInEffect = constraints.policyInEffect; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); @@ -130,42 +125,26 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { }); // now that outputs are set up, connect inputs - this.settings$().pipe(takeUntil(this.destroyed$)).subscribe(settings); - } - - protected settings$(): Observable> { - // save valid changes - const validChanges$ = this.settings.statusChanges.pipe( - filter((status) => status === "VALID"), + this.saveSettings.pipe( withLatestFrom(this.settings.valueChanges), + tap(([requestor, value]) => console.log(`save request from ${requestor}: ${JSON.stringify(value)}`)), map(([, settings]) => settings), - tap((value) => console.log(`valid change: ${JSON.stringify(value)}`)) - ); + takeUntil(this.destroyed$) + ).subscribe(settings); + } - // discards changes but keep the override setting that changed - const overrides = [Controls.capitalize, Controls.includeNumber]; - const overrideChanges$ = this.settings.valueChanges.pipe( - filter((settings) => !!settings), - withLatestFrom(this.okSettings$), - filter(([current, ok]) => overrides.some((c) => (current[c] ?? ok[c]) !== ok[c])), - map(([current, ok]) => { - const copy = { ...ok }; - for (const override of overrides) { - copy[override] = current[override]; - } - return copy; - }), - tap((value) => console.log(`override: ${JSON.stringify(value)}`)) - ); + /** attribute binding for numWords[min] */ + protected minNumWords: number; - // save reloaded settings when requested - const reloadChanges$ = this.reloadSettings$.pipe( - withLatestFrom(this.okSettings$), - map(([, settings]) => settings), - tap((value) => console.log(`reload: ${JSON.stringify(value)}`)) - ); + /** attribute binding for numWords[max] */ + protected maxNumWords: number; - return merge(validChanges$, overrideChanges$, reloadChanges$); + /** attribute binding for wordSeparator[maxlength] */ + protected wordSeparatorMaxLength: number; + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } /** display binding for enterprise policy notice */ @@ -173,21 +152,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { private okSettings$ = new ReplaySubject(1); - private reloadSettings$ = new Subject(); - - /** triggers a reload of the users' settings - * @param site labels the invocation site so that an operation - * can be traced back to its origin. Useful for debugging rxjs. - * @returns a promise that completes once a reload occurs. - */ - async reloadSettings(site: string = "component api call") { - const reloadComplete = firstValueFrom(this.okSettings$); - if (this.settings.invalid) { - this.reloadSettings$.next(site); - await reloadComplete; - } - } - private numWordsBoundariesHint = new ReplaySubject(1); /** display binding for min/max constraints of `numWords` */ From b3accc3dcc05275e90c14d4209d79b3a920b1b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 13:06:45 -0400 Subject: [PATCH 06/17] remove min/max boundaries to fix chrome display issue --- .../src/passphrase-settings.component.html | 20 +++++++++---- .../src/passphrase-settings.component.ts | 30 +++++-------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index e479a437a87..4e073f34243 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -12,8 +12,6 @@
{{ "options" | i18n }}
formControlName="numWords" id="num-words" type="number" - [min]="minNumWords" - [max]="maxNumWords" (change)="save('numWords')" /> {{ numWordsBoundariesHint$ | async }} @@ -34,13 +32,23 @@
{{ "options" | i18n }}
/> - + {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }}

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 4d6e9471fb4..4b07d30fb06 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -10,7 +10,6 @@ import { map, withLatestFrom, ReplaySubject, - tap, } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -85,7 +84,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { settings .pipe( filter((s) => !!s), - tap((value) => console.log(`update ok settings: ${JSON.stringify(value)}`)), takeUntil(this.destroyed$), ) .subscribe(this.okSettings$); @@ -98,18 +96,11 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - // dynamic policy enforcement + // explain policy & disable policy-overridden fields this.generatorService .policy$(Generators.passphrase, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - // reactive form validation doesn't work well with the generator's - // "auto-fix invalid data" feature. HTML constraints are used to - // improve usability. This approach causes `valueChanges` to fire - // *every time* this subscription fires. Take care not to leak these - // false emissions from the `onUpdated` event. - this.minNumWords = constraints.numWords.min; - this.maxNumWords = constraints.numWords.max; this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; this.policyInEffect = constraints.policyInEffect; @@ -125,20 +116,15 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { }); // now that outputs are set up, connect inputs - this.saveSettings.pipe( - withLatestFrom(this.settings.valueChanges), - tap(([requestor, value]) => console.log(`save request from ${requestor}: ${JSON.stringify(value)}`)), - map(([, settings]) => settings), - takeUntil(this.destroyed$) - ).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); } - /** attribute binding for numWords[min] */ - protected minNumWords: number; - - /** attribute binding for numWords[max] */ - protected maxNumWords: number; - /** attribute binding for wordSeparator[maxlength] */ protected wordSeparatorMaxLength: number; From dd23ce0165bda198adc77c38251ba17bd4f0d5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 13:19:21 -0400 Subject: [PATCH 07/17] bind settings components as view children of options components --- .../src/credential-generator.component.html | 5 ++++ .../src/credential-generator.component.ts | 26 +++++++++++++++-- .../src/password-generator.component.html | 2 ++ .../src/password-generator.component.ts | 20 ++++++++++++- .../src/username-generator.component.html | 4 +++ .../src/username-generator.component.ts | 28 ++++++++++++++++++- 6 files changed, 80 insertions(+), 5 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 5d48cdabc93..ec229d91d35 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 @@ {{ "options" | i18n }} {{ "options" | i18n }} Date: Fri, 1 Nov 2024 13:23:58 -0400 Subject: [PATCH 08/17] remove defunct `okSettings$` --- .../components/src/passphrase-settings.component.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 4b07d30fb06..f2f1749cb62 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -6,7 +6,6 @@ import { skip, takeUntil, Subject, - filter, map, withLatestFrom, ReplaySubject, @@ -81,12 +80,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { async ngOnInit() { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.passphrase, { singleUserId$ }); - settings - .pipe( - filter((s) => !!s), - takeUntil(this.destroyed$), - ) - .subscribe(this.okSettings$); // skips reactive event emissions to break a subscription cycle settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { @@ -136,8 +129,6 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { /** display binding for enterprise policy notice */ protected policyInEffect: boolean; - private okSettings$ = new ReplaySubject(1); - private numWordsBoundariesHint = new ReplaySubject(1); /** display binding for min/max constraints of `numWords` */ From 9b79bdbcd4c8693068fbe1a3a88aebdbfac68650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 13:33:54 -0400 Subject: [PATCH 09/17] extend validationless generator to passwords --- .../src/credential-generator.component.ts | 4 ++ .../src/password-generator.component.ts | 10 +++- .../src/password-settings.component.html | 46 +++++++++++++++---- .../src/password-settings.component.ts | 43 +++++++++-------- 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 8792cc8fcde..2ccad318d75 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -534,6 +534,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { await this.passphraseComponent.save("credential generator"); } + if (this.passwordComponent) { + await this.passwordComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 9eec116f23a..dac3b061d8c 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -90,7 +90,15 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { + if (this.passphraseComponent) { + await this.passphraseComponent.save("credential generator"); + } + + if (this.passwordComponent) { + await this.passwordComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index aa12a3247c3..9f8e00921fb 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -7,7 +7,7 @@

{{ "options" | i18n }}

{{ "length" | i18n }} - + {{ lengthBoundariesHint$ | async }} @@ -21,7 +21,12 @@

{{ "options" | i18n }}

attr.aria-description="{{ 'uppercaseDescription' | i18n }}" title="{{ 'uppercaseDescription' | i18n }}" > - + {{ "uppercaseLabel" | i18n }}
{{ "options" | i18n }} attr.aria-description="{{ 'lowercaseDescription' | i18n }}" title="{{ 'lowercaseDescription' | i18n }}" > - + {{ "lowercaseLabel" | i18n }} {{ "options" | i18n }} attr.aria-description="{{ 'numbersDescription' | i18n }}" title="{{ 'numbersDescription' | i18n }}" > - + {{ "numbersLabel" | i18n }} {{ "options" | i18n }} attr.aria-description="{{ 'specialCharactersDescription' | i18n }}" title="{{ 'specialCharactersDescription' | i18n }}" > - + {{ "specialCharactersLabel" | i18n }}
{{ "minNumbers" | i18n }} - + {{ "minSpecial" | i18n }} - +
- + {{ "avoidAmbiguous" | i18n }}

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 6e9d106b71a..677a3417b97 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,7 +1,17 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, takeUntil, Subject, map, filter, tap, skip, ReplaySubject } from "rxjs"; +import { + BehaviorSubject, + takeUntil, + Subject, + map, + filter, + tap, + skip, + ReplaySubject, + withLatestFrom, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,7 +22,7 @@ import { PasswordGenerationOptions, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ length: "length", @@ -118,23 +128,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.settings.patchValue(s, { emitEvent: false }); }); - // bind policy to the template + // explain policy & disable policy-overridden fields this.generatorService .policy$(Generators.password, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.settings - .get(Controls.length) - .setValidators(toValidators(Controls.length, Generators.password, constraints)); - - this.minNumber.setValidators( - toValidators(Controls.minNumber, Generators.password, constraints), - ); - - this.minSpecial.setValidators( - toValidators(Controls.minSpecial, Generators.password, constraints), - ); - this.policyInEffect = constraints.policyInEffect; const toggles = [ @@ -153,8 +151,8 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { const boundariesHint = this.i18nService.t( "generatorBoundariesHint", - constraints.length.min, - constraints.length.max, + constraints.length.min?.toString(), + constraints.length.max?.toString(), ); this.lengthBoundariesHint.next(boundariesHint); }); @@ -201,9 +199,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); // now that outputs are set up, connect inputs - this.settings.valueChanges + this.saveSettings .pipe( - map((settings) => { + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => { // interface is "avoid" while storage is "include" const s: any = { ...settings }; s.ambiguous = s.avoidAmbiguous; @@ -215,6 +214,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { .subscribe(settings); } + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); + } + /** display binding for enterprise policy notice */ protected policyInEffect: boolean; @@ -246,6 +250,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } From 1942a9232cc1e4f1ced47f490055d4d12281ddb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 13:56:53 -0400 Subject: [PATCH 10/17] extend validationless generator to catchall emails --- .../src/catchall-settings.component.html | 2 +- .../src/catchall-settings.component.ts | 45 ++++++++++++++++--- .../src/credential-generator.component.ts | 4 ++ .../src/password-generator.component.ts | 16 ++++--- .../src/subaddress-settings.component.ts | 2 +- .../src/username-generator.component.ts | 9 +++- 6 files changed, 62 insertions(+), 16 deletions(-) diff --git a/libs/tools/generator/components/src/catchall-settings.component.html b/libs/tools/generator/components/src/catchall-settings.component.html index 0b2a9e69ef3..7438da6c468 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.html +++ b/libs/tools/generator/components/src/catchall-settings.component.html @@ -1,6 +1,6 @@
{{ "domainName" | i18n }} - +
diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 55ddc1f8102..379b6d4c0de 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; +import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -12,6 +12,11 @@ import { import { completeOnAccountSwitch } from "./util"; +/** Splits an email into a username, subaddress, and domain named group. + * Subaddress is optional. + */ +export const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)"); + /** Options group for catchall emails */ @Component({ selector: "tools-catchall-settings", @@ -53,14 +58,43 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.catchall, { singleUserId$ }); - settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { - this.settings.patchValue(s, { emitEvent: false }); - }); + settings + .pipe( + withLatestFrom(this.accountService.activeAccount$), + map(([settings, activeAccount]) => { + // if the subaddress isn't specified, copy it from + // the user's settings + if ((settings.catchallDomain ?? "").trim().length < 1) { + const parsed = DOMAIN_PARSER.exec(activeAccount.email); + if (parsed) { + settings.catchallDomain = parsed.groups.domain; + } + } + + return settings; + }), + takeUntil(this.destroyed$), + ) + .subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + // now that outputs are set up, connect inputs + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -78,6 +112,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 2ccad318d75..211eef8301c 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -538,6 +538,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { await this.passwordComponent.save("credential generator"); } + if (this.catchallComponent) { + await this.catchallComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index dac3b061d8c..c67c8d30c14 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -211,13 +211,15 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { }); // generate on load unless the generator prohibits it - this.algorithm$ - .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), - takeUntil(this.destroyed), - ) - .subscribe(() => this.generate("autogenerate")); + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index bd6ca899db7..cefba063cee 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -59,7 +59,7 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { map(([settings, activeAccount]) => { // if the subaddress isn't specified, copy it from // the user's settings - if ((settings.subaddressEmail ?? "").length < 1) { + if ((settings.subaddressEmail ?? "").trim().length < 1) { settings.subaddressEmail = activeAccount.email; } diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 8d67c00778f..e9175bd9958 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -348,7 +348,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate("autogenerate"); + this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); } }); }); @@ -440,7 +440,11 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { + if (this.catchallComponent) { + await this.catchallComponent.save("credential generator"); + } + this.generate$.next(requestor); } @@ -455,6 +459,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { private readonly destroyed = new Subject(); ngOnDestroy() { + this.destroyed.next(); this.destroyed.complete(); // finalize subjects From bd3457c3839ff7973086001d999717cd09ac760b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 14:10:50 -0400 Subject: [PATCH 11/17] extend validationless generator to forwarder emails --- .../src/catchall-settings.component.html | 7 ++- .../src/credential-generator.component.ts | 4 ++ .../src/forwarder-settings.component.html | 18 +++++-- .../src/forwarder-settings.component.ts | 53 +++++++------------ .../src/username-generator.component.ts | 4 ++ 5 files changed, 49 insertions(+), 37 deletions(-) diff --git a/libs/tools/generator/components/src/catchall-settings.component.html b/libs/tools/generator/components/src/catchall-settings.component.html index 7438da6c468..61037c91a73 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.html +++ b/libs/tools/generator/components/src/catchall-settings.component.html @@ -1,6 +1,11 @@
{{ "domainName" | i18n }} - +
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 211eef8301c..da3aae42858 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -542,6 +542,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { await this.catchallComponent.save("credential generator"); } + if (this.forwarderComponent) { + await this.forwarderComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 64566fa9562..0e15c2e89ac 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -1,16 +1,28 @@
{{ "forwarderDomainName" | i18n }} - + {{ "forwarderDomainNameHint" | i18n }} {{ "apiKey" | i18n }} - + {{ "selfHostBaseUrl" | i18n }} - +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index 67e93c611ee..f1caf91ade1 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -17,7 +17,6 @@ import { skip, Subject, switchAll, - switchMap, takeUntil, withLatestFrom, } from "rxjs"; @@ -33,7 +32,7 @@ import { toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ domain: "domain", @@ -117,35 +116,17 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy this.settings.patchValue(settings as any, { emitEvent: false }); }); - // bind policy to the reactive form - forwarder$ - .pipe( - switchMap((forwarder) => { - const constraints$ = this.generatorService - .policy$(forwarder, { userId$: singleUserId$ }) - .pipe(map(({ constraints }) => [constraints, forwarder] as const)); - - return constraints$; - }), - takeUntil(this.destroyed$), - ) - .subscribe(([constraints, forwarder]) => { - for (const name in Controls) { - const control = this.settings.get(name); - if (forwarder.request.includes(name as any)) { - control.enable({ emitEvent: false }); - control.setValidators( - // the configuration's type erasure affects `toValidators` as well - toValidators(name, forwarder, constraints), - ); - } else { - control.disable({ emitEvent: false }); - control.clearValidators(); - } + // enable requested forwarder inputs + forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (forwarder.request.includes(name as any)) { + control.enable({ emitEvent: false }); + } else { + control.disable({ emitEvent: false }); } - - this.settings.updateValueAndValidity({ emitEvent: false }); - }); + } + }); // the first emission is the current value; subsequent emissions are updates settings$$ @@ -157,13 +138,18 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy .subscribe(this.onUpdated); // now that outputs are set up, connect inputs - this.settings.valueChanges - .pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) - .subscribe(([value, settings]) => { + this.saveSettings + .pipe(withLatestFrom(this.settings.valueChanges, settings$$), takeUntil(this.destroyed$)) + .subscribe(([, value, settings]) => { settings.next(value); }); } + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); + } + ngOnChanges(changes: SimpleChanges): void { this.refresh$.complete(); if ("forwarder" in changes) { @@ -192,6 +178,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index e9175bd9958..463951bc300 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -445,6 +445,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { await this.catchallComponent.save("credential generator"); } + if (this.forwarderComponent) { + await this.forwarderComponent.save("credential generator"); + } + this.generate$.next(requestor); } From e8f6e15967aa7695f5881950d418f4b0b95e7900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 14:14:33 -0400 Subject: [PATCH 12/17] extend validationless generator to subaddress emails --- .../src/credential-generator.component.ts | 5 +++++ .../src/subaddress-settings.component.html | 7 ++++++- .../src/subaddress-settings.component.ts | 14 +++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index da3aae42858..aa65e64ee12 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -546,6 +546,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { await this.forwarderComponent.save("credential generator"); } + if (this.subaddressComponent) { + await this.subaddressComponent.save("credential generator"); + } + this.generate$.next(requestor); } @@ -560,6 +564,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { private readonly destroyed = new Subject(); ngOnDestroy() { + this.destroyed.next(); this.destroyed.complete(); // finalize subjects diff --git a/libs/tools/generator/components/src/subaddress-settings.component.html b/libs/tools/generator/components/src/subaddress-settings.component.html index 16f3aea28bf..1dfb5e3460d 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.html +++ b/libs/tools/generator/components/src/subaddress-settings.component.html @@ -1,6 +1,11 @@
{{ "email" | i18n }} - +
diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index cefba063cee..c778fec1c9c 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -74,7 +74,18 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -92,6 +103,7 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } From aec68c3b136c8418974907476c1ef22f49257f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 14:18:47 -0400 Subject: [PATCH 13/17] extend validationless generator to usernames --- .../src/credential-generator.component.ts | 4 ++++ .../src/username-generator.component.ts | 8 ++++++++ .../src/username-settings.component.html | 14 ++++++++++++-- .../src/username-settings.component.ts | 16 ++++++++++++++-- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index aa65e64ee12..6c67692b01b 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -550,6 +550,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { await this.subaddressComponent.save("credential generator"); } + if (this.usernameComponent) { + await this.usernameComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 463951bc300..0795b5426c6 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -449,6 +449,14 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { await this.forwarderComponent.save("credential generator"); } + if (this.subaddressComponent) { + await this.subaddressComponent.save("credential generator"); + } + + if (this.usernameComponent) { + await this.usernameComponent.save("credential generator"); + } + this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/username-settings.component.html b/libs/tools/generator/components/src/username-settings.component.html index 4a4f8cd9feb..649cd052e7b 100644 --- a/libs/tools/generator/components/src/username-settings.component.html +++ b/libs/tools/generator/components/src/username-settings.component.html @@ -1,10 +1,20 @@
- + {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }}
diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index 8237b8674cd..05a46feaaa8 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; +import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -61,7 +61,18 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -79,6 +90,7 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } From e02e08c9470d213dffe652770e0b850d1743435d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 15:21:05 -0400 Subject: [PATCH 14/17] fix observable cycle --- .../src/credential-generator.component.ts | 60 +------------------ .../src/password-generator.component.ts | 28 +-------- .../src/username-generator.component.ts | 44 +------------- 3 files changed, 3 insertions(+), 129 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 6c67692b01b..8c8f0ec1bcf 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -1,13 +1,4 @@ -import { - Component, - EventEmitter, - Input, - NgZone, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, @@ -48,13 +39,6 @@ import { } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; -import { CatchallSettingsComponent } from "./catchall-settings.component"; -import { ForwarderSettingsComponent } from "./forwarder-settings.component"; -import { PassphraseSettingsComponent } from "./passphrase-settings.component"; -import { PasswordSettingsComponent } from "./password-settings.component"; -import { SubaddressSettingsComponent } from "./subaddress-settings.component"; -import { UsernameSettingsComponent } from "./username-settings.component"; - // constants used to identify navigation selections that are not // generator algorithms const IDENTIFIER = "identifier"; @@ -77,24 +61,6 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { private formBuilder: FormBuilder, ) {} - /** binds to the settings component at runtime */ - @ViewChild("passphraseSettings") passphraseComponent: PassphraseSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("passwordSettings") passwordComponent: PasswordSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("catchallSettings") catchallComponent: CatchallSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("forwarderSettings") forwarderComponent: ForwarderSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("subaddressSettings") subaddressComponent: SubaddressSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("usernameSettings") usernameComponent: UsernameSettingsComponent; - /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @@ -530,30 +496,6 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { * origin in the debugger. */ protected async generate(requestor: string) { - if (this.passphraseComponent) { - await this.passphraseComponent.save("credential generator"); - } - - if (this.passwordComponent) { - await this.passwordComponent.save("credential generator"); - } - - if (this.catchallComponent) { - await this.catchallComponent.save("credential generator"); - } - - if (this.forwarderComponent) { - await this.forwarderComponent.save("credential generator"); - } - - if (this.subaddressComponent) { - await this.subaddressComponent.save("credential generator"); - } - - if (this.usernameComponent) { - await this.usernameComponent.save("credential generator"); - } - this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index c67c8d30c14..e6c80278aa0 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,14 +1,5 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; -import { - Component, - EventEmitter, - Input, - NgZone, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { BehaviorSubject, catchError, @@ -39,9 +30,6 @@ import { } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; -import { PassphraseSettingsComponent } from "./passphrase-settings.component"; -import { PasswordSettingsComponent } from "./password-settings.component"; - /** Options group for passwords */ @Component({ selector: "tools-password-generator", @@ -58,12 +46,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { private zone: NgZone, ) {} - /** binds to the settings component at runtime */ - @ViewChild("passphraseSettings") passphraseComponent: PassphraseSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("passwordSettings") passwordComponent: PasswordSettingsComponent; - /** Binds the component to a specific user's settings. * When this input is not provided, the form binds to the active * user @@ -91,14 +73,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { * origin in the debugger. */ protected async generate(requestor: string) { - if (this.passphraseComponent) { - await this.passphraseComponent.save("credential generator"); - } - - if (this.passwordComponent) { - await this.passwordComponent.save("credential generator"); - } - this.generate$.next(requestor); } diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 0795b5426c6..3ac24ccd201 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,14 +1,5 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; -import { - Component, - EventEmitter, - Input, - NgZone, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; +import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, @@ -47,11 +38,6 @@ import { } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; -import { CatchallSettingsComponent } from "./catchall-settings.component"; -import { ForwarderSettingsComponent } from "./forwarder-settings.component"; -import { SubaddressSettingsComponent } from "./subaddress-settings.component"; -import { UsernameSettingsComponent } from "./username-settings.component"; - // constants used to identify navigation selections that are not // generator algorithms const FORWARDER = "forwarder"; @@ -81,18 +67,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { private formBuilder: FormBuilder, ) {} - /** binds to the settings component at runtime */ - @ViewChild("catchallSettings") catchallComponent: CatchallSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("forwarderSettings") forwarderComponent: ForwarderSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("subaddressSettings") subaddressComponent: SubaddressSettingsComponent; - - /** binds to the settings component at runtime */ - @ViewChild("usernameSettings") usernameComponent: UsernameSettingsComponent; - /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @@ -441,22 +415,6 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { * origin in the debugger. */ protected async generate(requestor: string) { - if (this.catchallComponent) { - await this.catchallComponent.save("credential generator"); - } - - if (this.forwarderComponent) { - await this.forwarderComponent.save("credential generator"); - } - - if (this.subaddressComponent) { - await this.subaddressComponent.save("credential generator"); - } - - if (this.usernameComponent) { - await this.usernameComponent.save("credential generator"); - } - this.generate$.next(requestor); } From 703b310a3bd36daec359a0344284a76477f8fe91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 15:27:25 -0400 Subject: [PATCH 15/17] disable generate button when no algorithm is selected --- .../components/src/credential-generator.component.html | 2 ++ .../generator/components/src/password-generator.component.html | 2 ++ .../generator/components/src/username-generator.component.html | 2 ++ 3 files changed, 6 insertions(+) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index ec229d91d35..0182bd1c204 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -22,6 +22,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -33,6 +34,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" > diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index c1f4e30230a..81e18ed02a9 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -20,6 +20,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -31,6 +32,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" > diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index d8880d724d9..f96374e063b 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -9,6 +9,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -20,6 +21,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeCopyLabel$ | async }} From c75909b0a16689012f4e7f94a3cef0b06cd743c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 16:10:12 -0400 Subject: [PATCH 16/17] prevent duplicate algorithm emissions --- .../src/credential-generator.component.ts | 5 ++- .../src/password-generator.component.ts | 33 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 8c8f0ec1bcf..6af15336120 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -202,9 +202,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); }); - // normalize cascade selections; introduce subjects to allow changes - // from user selections and changes from preference updates to - // update the template + // these subjects normalize cascade selections to ensure the current + // cascade is always well-known. type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; const activeRoot$ = new Subject(); const activeIdentifier$ = new Subject(); diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index e6c80278aa0..715904dc984 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -22,11 +22,11 @@ import { Option } from "@bitwarden/components/src/select/option"; import { CredentialGeneratorService, Generators, - PasswordAlgorithm, GeneratedCredential, CredentialAlgorithm, isPasswordAlgorithm, AlgorithmInfo, + isSameAlgorithm, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; @@ -57,7 +57,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject(null); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -79,7 +79,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { /** Tracks changes to the selected credential type * @param type the new credential type */ - protected onCredentialTypeChanged(type: PasswordAlgorithm) { + protected onCredentialTypeChanged(type: CredentialAlgorithm) { // break subscription cycle if (this.credentialType$.value !== type) { this.zone.run(() => { @@ -169,20 +169,23 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { preferences.next(preference); }); - // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => { - // update navigation - this.onCredentialTypeChanged(password.algorithm); - - // load algorithm metadata - const algorithm = this.generatorService.algorithm(password.algorithm); + // update active algorithm + preferences + .pipe( + map(({ password }) => this.generatorService.algorithm(password.algorithm)), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update navigation + this.onCredentialTypeChanged(algorithm.id); - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); }); - }); // generate on load unless the generator prohibits it this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { From c356c7a19fbdbae0b14afb48cc5b21ff56635b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 1 Nov 2024 18:09:39 -0400 Subject: [PATCH 17/17] add constraints that assign email address defaults --- libs/common/src/tools/state/object-key.ts | 1 + .../src/tools/state/user-state-subject.ts | 7 +- .../src/catchall-settings.component.ts | 23 +-- .../components/src/generator.module.ts | 2 + .../src/subaddress-settings.component.ts | 20 +- .../generator/core/src/data/generators.ts | 175 +++++++++++------- .../core/src/policies/catchall-constraints.ts | 45 +++++ .../src/policies/subaddress-constraints.ts | 34 ++++ libs/tools/generator/core/src/rx.ts | 3 +- .../credential-generator.service.spec.ts | 45 +++++ .../services/credential-generator.service.ts | 27 ++- .../core/src/types/policy-configuration.ts | 6 +- .../send-ui/src/send-form/send-form.module.ts | 2 + 13 files changed, 274 insertions(+), 116 deletions(-) create mode 100644 libs/tools/generator/core/src/policies/catchall-constraints.ts create mode 100644 libs/tools/generator/core/src/policies/subaddress-constraints.ts diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts index 88365d5cbd1..0593186ec43 100644 --- a/libs/common/src/tools/state/object-key.ts +++ b/libs/common/src/tools/state/object-key.ts @@ -22,6 +22,7 @@ export type ObjectKey> classifier: Classifier; format: "plain" | "classified"; options: UserKeyDefinitionOptions; + initial?: State; }; export function isObjectKey(key: any): key is ObjectKey { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 89f19ac3c73..845ab25c808 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -254,17 +254,18 @@ export class UserStateSubject< withConstraints, map(([loadedState, constraints]) => { // bypass nulls - if (!loadedState) { + if (!loadedState && !this.objectKey?.initial) { return { constraints: {} as Constraints, state: null, } satisfies Constrained; } + const unconstrained = loadedState ?? structuredClone(this.objectKey.initial); const calibration = isDynamic(constraints) - ? constraints.calibrate(loadedState) + ? constraints.calibrate(unconstrained) : constraints; - const adjusted = calibration.adjust(loadedState); + const adjusted = calibration.adjust(unconstrained); return { constraints: calibration.constraints, diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 379b6d4c0de..74fb37d2335 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -58,26 +58,9 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.catchall, { singleUserId$ }); - settings - .pipe( - withLatestFrom(this.accountService.activeAccount$), - map(([settings, activeAccount]) => { - // if the subaddress isn't specified, copy it from - // the user's settings - if ((settings.catchallDomain ?? "").trim().length < 1) { - const parsed = DOMAIN_PARSER.exec(activeAccount.email); - if (parsed) { - settings.catchallDomain = parsed.groups.domain; - } - } - - return settings; - }), - takeUntil(this.destroyed$), - ) - .subscribe((s) => { - this.settings.patchValue(s, { emitEvent: false }); - }); + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 2d1cedca400..e73d687d7dd 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -7,6 +7,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -79,6 +80,7 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); I18nService, EncryptService, KeyService, + AccountService, ], }), ], diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index c778fec1c9c..5a310c8defb 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -53,23 +53,9 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ }); - settings - .pipe( - withLatestFrom(this.accountService.activeAccount$), - map(([settings, activeAccount]) => { - // if the subaddress isn't specified, copy it from - // the user's settings - if ((settings.subaddressEmail ?? "").trim().length < 1) { - settings.subaddressEmail = activeAccount.email; - } - - return settings; - }), - takeUntil(this.destroyed$), - ) - .subscribe((s) => { - this.settings.patchValue(s, { emitEvent: false }); - }); + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 6090fe789cb..6ddea595ec7 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,7 +1,10 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { EmailRandomizer, @@ -19,12 +22,12 @@ import { PasswordGeneratorOptionsEvaluator, passwordLeastPrivilege, } from "../policies"; +import { CatchallConstraints } from "../policies/catchall-constraints"; +import { SubaddressConstraints } from "../policies/subaddress-constraints"; import { - CATCHALL_SETTINGS, EFF_USERNAME_SETTINGS, PASSPHRASE_SETTINGS, PASSWORD_SETTINGS, - SUBADDRESS_SETTINGS, } from "../strategies/storage"; import { CatchallGenerationOptions, @@ -178,79 +181,115 @@ const USERNAME = Object.freeze({ }, } satisfies CredentialGeneratorConfiguration); -const CATCHALL = Object.freeze({ - id: "catchall", - category: "email", - nameKey: "catchallEmail", - descriptionKey: "catchallEmailDesc", - generateKey: "generateEmail", - generatedValueKey: "email", - copyKey: "copyEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultCatchallOptions, - constraints: { catchallDomain: { minLength: 1 } }, - account: CATCHALL_SETTINGS, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; +const CATCHALL: CredentialGeneratorConfiguration = + Object.freeze({ + id: "catchall", + category: "email", + nameKey: "catchallEmail", + descriptionKey: "catchallEmailDesc", + generateKey: "generateEmail", + generatedValueKey: "email", + copyKey: "copyEmail", + onlyOnRequest: false, + request: [], + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); + settings: { + initial: DefaultCatchallOptions, + constraints: { catchallDomain: { minLength: 1 } }, + account: { + key: "catchallGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "catchallType", + "catchallDomain", + ]), + state: GENERATOR_DISK, + initial: { + catchallType: "random", + catchallDomain: "", + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy, email: string) { + return new CatchallConstraints(email); + }, }, - }, -} satisfies CredentialGeneratorConfiguration); + }); -const SUBADDRESS = Object.freeze({ - id: "subaddress", - category: "email", - nameKey: "plusAddressedEmail", - descriptionKey: "plusAddressedEmailDesc", - generateKey: "generateEmail", - generatedValueKey: "email", - copyKey: "copyEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultSubaddressOptions, - constraints: {}, - account: SUBADDRESS_SETTINGS, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; +const SUBADDRESS: CredentialGeneratorConfiguration = + Object.freeze({ + id: "subaddress", + category: "email", + nameKey: "plusAddressedEmail", + descriptionKey: "plusAddressedEmailDesc", + generateKey: "generateEmail", + generatedValueKey: "email", + copyKey: "copyEmail", + onlyOnRequest: false, + request: [], + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); + settings: { + initial: DefaultSubaddressOptions, + constraints: {}, + account: { + key: "subaddressGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "subaddressType", + "subaddressEmail", + ]), + state: GENERATOR_DISK, + initial: { + subaddressType: "random", + subaddressEmail: "", + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy, email: string) { + return new SubaddressConstraints(email); + }, }, - }, -} satisfies CredentialGeneratorConfiguration); + }); export function toCredentialGeneratorConfiguration( configuration: ForwarderConfiguration, diff --git a/libs/tools/generator/core/src/policies/catchall-constraints.ts b/libs/tools/generator/core/src/policies/catchall-constraints.ts new file mode 100644 index 00000000000..37f62f874c6 --- /dev/null +++ b/libs/tools/generator/core/src/policies/catchall-constraints.ts @@ -0,0 +1,45 @@ +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { CatchallGenerationOptions } from "../types"; + +/** Parses the domain part of an email address + */ +const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)"); + +/** A constraint that sets the catchall domain using a fixed email address */ +export class CatchallConstraints implements StateConstraints { + /** Creates a catchall constraints + * @param email - the email address containing the domain. + */ + constructor(email: string) { + if (!email) { + this.domain = ""; + return; + } + + const parsed = DOMAIN_PARSER.exec(email); + if (parsed && parsed.groups?.domain) { + this.domain = parsed.groups.domain; + } + } + private domain: string; + + constraints: Readonly> = {}; + + adjust(state: CatchallGenerationOptions) { + const currentDomain = (state.catchallDomain ?? "").trim(); + + if (currentDomain !== "") { + return state; + } + + const options = { ...state }; + options.catchallDomain = this.domain; + + return options; + } + + fix(state: CatchallGenerationOptions) { + return state; + } +} diff --git a/libs/tools/generator/core/src/policies/subaddress-constraints.ts b/libs/tools/generator/core/src/policies/subaddress-constraints.ts new file mode 100644 index 00000000000..db05f712cf2 --- /dev/null +++ b/libs/tools/generator/core/src/policies/subaddress-constraints.ts @@ -0,0 +1,34 @@ +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { SubaddressGenerationOptions } from "../types"; + +/** A constraint that sets the subaddress email using a fixed email address */ +export class SubaddressConstraints implements StateConstraints { + /** Creates a catchall constraints + * @param email - the email address containing the domain. + */ + constructor(readonly email: string) { + if (!email) { + this.email = ""; + } + } + + constraints: Readonly> = {}; + + adjust(state: SubaddressGenerationOptions) { + const currentDomain = (state.subaddressEmail ?? "").trim(); + + if (currentDomain !== "") { + return state; + } + + const options = { ...state }; + options.subaddressEmail = this.email; + + return options; + } + + fix(state: SubaddressGenerationOptions) { + return state; + } +} diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 070d34d37d8..44d23ef1c5c 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -23,11 +23,12 @@ export function mapPolicyToEvaluator( */ export function mapPolicyToConstraints( configuration: PolicyConfiguration, + email: string, ) { return pipe( reduceCollection(configuration.combine, configuration.disabledValue), distinctIfShallowMatch(), - map(configuration.toConstraints), + map((policy) => configuration.toConstraints(policy, email)), ); } diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 225745e5f95..bd26642157e 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -202,6 +202,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -223,6 +224,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -248,6 +250,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -276,6 +279,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); @@ -297,6 +301,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); let error = null; @@ -322,6 +327,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); let completed = false; @@ -348,6 +354,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -368,6 +375,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.pipe(filter((u) => !!u)); @@ -392,6 +400,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser); let error = null; @@ -417,6 +426,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser); let completed = false; @@ -443,6 +453,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); const results: any[] = []; @@ -485,6 +496,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); let error: any = null; @@ -511,6 +523,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); let complete = false; @@ -542,6 +555,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("password"); @@ -563,6 +577,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("username"); @@ -583,6 +598,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("email"); @@ -604,6 +620,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms(["username", "email"]); @@ -629,6 +646,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("password")); @@ -646,6 +664,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("username")); @@ -662,6 +681,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("email")); @@ -679,6 +699,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$(["username", "email"])); @@ -701,6 +722,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$(["password"])); @@ -726,6 +748,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const results: any = []; const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); @@ -763,6 +786,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); @@ -784,6 +808,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -814,6 +839,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -840,6 +866,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -866,6 +893,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -898,6 +926,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -916,6 +945,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -936,6 +966,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -961,6 +992,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -986,6 +1018,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); @@ -1007,6 +1040,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1034,6 +1068,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1060,6 +1095,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1086,6 +1122,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1118,6 +1155,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); @@ -1139,6 +1177,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); let completed = false; @@ -1165,6 +1204,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); @@ -1182,6 +1222,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); @@ -1201,6 +1242,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1230,6 +1272,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1260,6 +1303,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1286,6 +1330,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); 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 04413ba2c0d..8c971b0d61b 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -23,6 +23,7 @@ import { Simplify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -98,6 +99,7 @@ export class CredentialGeneratorService { private readonly i18nService: I18nService, private readonly encryptService: EncryptService, private readonly keyService: KeyService, + private readonly accountService: AccountService, ) {} private getDependencyProvider(): GeneratorDependencyProvider { @@ -380,17 +382,30 @@ export class CredentialGeneratorService { configuration: Configuration, dependencies: Policy$Dependencies, ): Observable> { - const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true)); + const email$ = dependencies.userId$.pipe( + distinctUntilChanged(), + withLatestFrom(this.accountService.accounts$), + filter((accounts) => !!accounts), + map(([userId, accounts]) => { + if (userId in accounts) { + return { userId, email: accounts[userId].email }; + } + + return { userId, email: null }; + }), + ); - const constraints$ = dependencies.userId$.pipe( - switchMap((userId) => { - // complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely + const constraints$ = email$.pipe( + switchMap(({ userId, email }) => { + // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely const policies$ = this.policyService .getAll$(configuration.policy.type, userId) - .pipe(takeUntil(completion$)); + .pipe( + mapPolicyToConstraints(configuration.policy, email), + takeUntil(anyComplete(email$)), + ); return policies$; }), - mapPolicyToConstraints(configuration.policy), ); return constraints$; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index 2b01a04b92e..07ded886609 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -24,9 +24,13 @@ export type PolicyConfiguration = { createEvaluator: (policy: Policy) => PolicyEvaluator; /** Converts policy service data into actionable policy constraints. + * + * @param policy - the policy to map into policy constraints. + * @param email - the default email to extend. + * * @remarks this version includes constraints needed for the reactive forms; * it was introduced so that the constraints can be incrementally introduced * as the new UI is built. */ - toConstraints: (policy: Policy) => GeneratorConstraints; + toConstraints: (policy: Policy, email: string) => GeneratorConstraints; }; diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts index 67f1f910cc8..ec51c2c0e32 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form.module.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -4,6 +4,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -43,6 +44,7 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); I18nService, EncryptService, KeyService, + AccountService, ], }), ],