1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-21 16:18:28 +01:00

[PM-13876] replace angular validation with html constraints validation (#11816)

* rough-in passphrase validation failure handling

* trigger valid change from settings

* fix `max` constraint enforcement

* add taps for generator validation monitoring/debugging

* HTML constraints validation rises like a phoenix

* remove min/max boundaries to fix chrome display issue

* bind settings components as view children of options components

* remove defunct `okSettings$`

* extend validationless generator to passwords

* extend validationless generator to catchall emails

* extend validationless generator to forwarder emails

* extend validationless generator to subaddress emails

* extend validationless generator to usernames

* fix observable cycle

* disable generate button when no algorithm is selected

* prevent duplicate algorithm emissions

* add constraints that assign email address defaults
This commit is contained in:
✨ Audrey ✨ 2024-11-06 11:54:29 -05:00 committed by GitHub
parent a9595b4d14
commit 414bdde232
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 552 additions and 218 deletions

View File

@ -22,6 +22,7 @@ export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>>
classifier: Classifier<State, Disclosed, Secret>; classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified"; format: "plain" | "classified";
options: UserKeyDefinitionOptions<State>; options: UserKeyDefinitionOptions<State>;
initial?: State;
}; };
export function isObjectKey(key: any): key is ObjectKey<unknown> { export function isObjectKey(key: any): key is ObjectKey<unknown> {

View File

@ -254,17 +254,18 @@ export class UserStateSubject<
withConstraints, withConstraints,
map(([loadedState, constraints]) => { map(([loadedState, constraints]) => {
// bypass nulls // bypass nulls
if (!loadedState) { if (!loadedState && !this.objectKey?.initial) {
return { return {
constraints: {} as Constraints<State>, constraints: {} as Constraints<State>,
state: null, state: null,
} satisfies Constrained<State>; } satisfies Constrained<State>;
} }
const unconstrained = loadedState ?? structuredClone(this.objectKey.initial);
const calibration = isDynamic(constraints) const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState) ? constraints.calibrate(unconstrained)
: constraints; : constraints;
const adjusted = calibration.adjust(loadedState); const adjusted = calibration.adjust(unconstrained);
return { return {
constraints: calibration.constraints, constraints: calibration.constraints,

View File

@ -1,6 +1,11 @@
<form class="box" [formGroup]="settings" class="tw-container"> <form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field> <bit-form-field>
<bit-label>{{ "domainName" | i18n }}</bit-label> <bit-label>{{ "domainName" | i18n }}</bit-label>
<input bitInput formControlName="catchallDomain" type="text" /> <input
bitInput
formControlName="catchallDomain"
type="text"
(change)="save('catchallDomain')"
/>
</bit-form-field> </bit-form-field>
</form> </form>

View File

@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@ -12,6 +12,11 @@ import {
import { completeOnAccountSwitch } from "./util"; import { completeOnAccountSwitch } from "./util";
/** Splits an email into a username, subaddress, and domain named group.
* Subaddress is optional.
*/
export const DOMAIN_PARSER = new RegExp("[^@]+@(?<domain>.+)");
/** Options group for catchall emails */ /** Options group for catchall emails */
@Component({ @Component({
selector: "tools-catchall-settings", selector: "tools-catchall-settings",
@ -60,7 +65,19 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
// the first emission is the current value; subsequent emissions are updates // the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); 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<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
} }
private singleUserId$() { private singleUserId$() {
@ -78,6 +95,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -22,6 +22,7 @@
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
> >
{{ credentialTypeGenerateLabel$ | async }} {{ credentialTypeGenerateLabel$ | async }}
</button> </button>
@ -33,16 +34,19 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async" [appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async" [valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
></button> ></button>
</div> </div>
</bit-card> </bit-card>
<tools-password-settings <tools-password-settings
#passwordSettings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'password'" *ngIf="(showAlgorithm$ | async)?.id === 'password'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate('password settings')" (onUpdated)="generate('password settings')"
/> />
<tools-passphrase-settings <tools-passphrase-settings
#passphraseSettings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(showAlgorithm$ | async)?.id === 'passphrase'" *ngIf="(showAlgorithm$ | async)?.id === 'passphrase'"
[userId]="userId$ | async" [userId]="userId$ | async"
@ -80,21 +84,25 @@
</bit-form-field> </bit-form-field>
</form> </form>
<tools-catchall-settings <tools-catchall-settings
#catchallSettings
*ngIf="(showAlgorithm$ | async)?.id === 'catchall'" *ngIf="(showAlgorithm$ | async)?.id === 'catchall'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate('catchall settings')" (onUpdated)="generate('catchall settings')"
/> />
<tools-forwarder-settings <tools-forwarder-settings
#forwarderSettings
*ngIf="!!(forwarderId$ | async)" *ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async" [forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
/> />
<tools-subaddress-settings <tools-subaddress-settings
#subaddressSettings
*ngIf="(showAlgorithm$ | async)?.id === 'subaddress'" *ngIf="(showAlgorithm$ | async)?.id === 'subaddress'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate('subaddress settings')" (onUpdated)="generate('subaddress settings')"
/> />
<tools-username-settings <tools-username-settings
#usernameSettings
*ngIf="(showAlgorithm$ | async)?.id === 'username'" *ngIf="(showAlgorithm$ | async)?.id === 'username'"
[userId]="userId$ | async" [userId]="userId$ | async"
(onUpdated)="generate('username settings')" (onUpdated)="generate('username settings')"

View File

@ -202,9 +202,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
}); });
}); });
// normalize cascade selections; introduce subjects to allow changes // these subjects normalize cascade selections to ensure the current
// from user selections and changes from preference updates to // cascade is always well-known.
// update the template
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
const activeRoot$ = new Subject<CascadeValue>(); const activeRoot$ = new Subject<CascadeValue>();
const activeIdentifier$ = new Subject<CascadeValue>(); const activeIdentifier$ = new Subject<CascadeValue>();
@ -385,7 +384,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.value$.next("-"); this.value$.next("-");
} else { } else {
this.generate("autogenerate"); this.generate("autogenerate").catch((e: unknown) => this.logService.error(e));
} }
}); });
}); });
@ -495,7 +494,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next(requestor);
} }
@ -510,6 +509,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
private readonly destroyed = new Subject<void>(); private readonly destroyed = new Subject<void>();
ngOnDestroy() { ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete(); this.destroyed.complete();
// finalize subjects // finalize subjects

View File

@ -1,16 +1,28 @@
<form class="box" [formGroup]="settings" class="tw-container"> <form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field *ngIf="displayDomain"> <bit-form-field *ngIf="displayDomain">
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label> <bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
<input bitInput formControlName="domain" type="text" placeholder="example.com" /> <input
bitInput
formControlName="domain"
type="text"
placeholder="example.com"
(change)="save('domain')"
/>
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint> <bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
</bit-form-field> </bit-form-field>
<bit-form-field *ngIf="displayToken"> <bit-form-field *ngIf="displayToken">
<bit-label>{{ "apiKey" | i18n }}</bit-label> <bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput formControlName="token" type="password" /> <input bitInput formControlName="token" type="password" />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button> <button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
(change)="save('token')"
></button>
</bit-form-field> </bit-form-field>
<bit-form-field *ngIf="displayBaseUrl" disableMargin> <bit-form-field *ngIf="displayBaseUrl" disableMargin>
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label> <bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
<input bitInput formControlName="baseUrl" type="text" /> <input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" />
</bit-form-field> </bit-form-field>
</form> </form>

View File

@ -17,7 +17,6 @@ import {
skip, skip,
Subject, Subject,
switchAll, switchAll,
switchMap,
takeUntil, takeUntil,
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
@ -33,7 +32,7 @@ import {
toCredentialGeneratorConfiguration, toCredentialGeneratorConfiguration,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util"; import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
domain: "domain", domain: "domain",
@ -117,35 +116,17 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
this.settings.patchValue(settings as any, { emitEvent: false }); this.settings.patchValue(settings as any, { emitEvent: false });
}); });
// bind policy to the reactive form // enable requested forwarder inputs
forwarder$ forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => {
.pipe( for (const name in Controls) {
switchMap((forwarder) => { const control = this.settings.get(name);
const constraints$ = this.generatorService if (forwarder.request.includes(name as any)) {
.policy$(forwarder, { userId$: singleUserId$ }) control.enable({ emitEvent: false });
.pipe(map(({ constraints }) => [constraints, forwarder] as const)); } else {
control.disable({ emitEvent: false });
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();
}
} }
}
this.settings.updateValueAndValidity({ emitEvent: false }); });
});
// the first emission is the current value; subsequent emissions are updates // the first emission is the current value; subsequent emissions are updates
settings$$ settings$$
@ -157,13 +138,18 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
.subscribe(this.onUpdated); .subscribe(this.onUpdated);
// now that outputs are set up, connect inputs // now that outputs are set up, connect inputs
this.settings.valueChanges this.saveSettings
.pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) .pipe(withLatestFrom(this.settings.valueChanges, settings$$), takeUntil(this.destroyed$))
.subscribe(([value, settings]) => { .subscribe(([, value, settings]) => {
settings.next(value); settings.next(value);
}); });
} }
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
this.refresh$.complete(); this.refresh$.complete();
if ("forwarder" in changes) { if ("forwarder" in changes) {
@ -192,6 +178,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -7,6 +7,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
@ -79,6 +80,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
I18nService, I18nService,
EncryptService, EncryptService,
KeyService, KeyService,
AccountService,
], ],
}), }),
], ],

View File

@ -7,7 +7,13 @@
<bit-card> <bit-card>
<bit-form-field disableMargin> <bit-form-field disableMargin>
<bit-label>{{ "numWords" | i18n }}</bit-label> <bit-label>{{ "numWords" | i18n }}</bit-label>
<input bitInput formControlName="numWords" id="num-words" type="number" /> <input
bitInput
formControlName="numWords"
id="num-words"
type="number"
(change)="save('numWords')"
/>
<bit-hint>{{ numWordsBoundariesHint$ | async }}</bit-hint> <bit-hint>{{ numWordsBoundariesHint$ | async }}</bit-hint>
</bit-form-field> </bit-form-field>
</bit-card> </bit-card>
@ -16,14 +22,33 @@
<bit-card> <bit-card>
<bit-form-field> <bit-form-field>
<bit-label>{{ "wordSeparator" | i18n }}</bit-label> <bit-label>{{ "wordSeparator" | i18n }}</bit-label>
<input bitInput formControlName="wordSeparator" id="word-separator" type="text" /> <input
bitInput
formControlName="wordSeparator"
id="word-separator"
type="text"
[maxlength]="wordSeparatorMaxLength"
(change)="save('wordSeparator')"
/>
</bit-form-field> </bit-form-field>
<bit-form-control> <bit-form-control>
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" /> <input
bitCheckbox
formControlName="capitalize"
id="capitalize"
type="checkbox"
(change)="save('capitalize')"
/>
<bit-label>{{ "capitalize" | i18n }}</bit-label> <bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect"> <bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" /> <input
bitCheckbox
formControlName="includeNumber"
id="include-number"
type="checkbox"
(change)="save('includeNumber')"
/>
<bit-label>{{ "includeNumber" | i18n }}</bit-label> <bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> <p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>

View File

@ -1,7 +1,15 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, takeUntil, Subject, ReplaySubject } from "rxjs"; import {
BehaviorSubject,
skip,
takeUntil,
Subject,
map,
withLatestFrom,
ReplaySubject,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -12,7 +20,7 @@ import {
PassphraseGenerationOptions, PassphraseGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util"; import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
numWords: "numWords", numWords: "numWords",
@ -81,21 +89,12 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
// the first emission is the current value; subsequent emissions are updates // the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// dynamic policy enforcement // explain policy & disable policy-overridden fields
this.generatorService this.generatorService
.policy$(Generators.passphrase, { userId$: singleUserId$ }) .policy$(Generators.passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .subscribe(({ constraints }) => {
this.settings this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength;
.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 });
this.policyInEffect = constraints.policyInEffect; this.policyInEffect = constraints.policyInEffect;
this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly);
@ -110,7 +109,21 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
}); });
// now that outputs are set up, connect inputs // now that outputs are set up, connect inputs
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); this.saveSettings
.pipe(
withLatestFrom(this.settings.valueChanges),
map(([, settings]) => settings),
takeUntil(this.destroyed$),
)
.subscribe(settings);
}
/** attribute binding for wordSeparator[maxlength] */
protected wordSeparatorMaxLength: number;
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
} }
/** display binding for enterprise policy notice */ /** display binding for enterprise policy notice */
@ -144,6 +157,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -20,6 +20,7 @@
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
> >
{{ credentialTypeGenerateLabel$ | async }} {{ credentialTypeGenerateLabel$ | async }}
</button> </button>
@ -31,10 +32,12 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async" [appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async" [valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
></button> ></button>
</div> </div>
</bit-card> </bit-card>
<tools-password-settings <tools-password-settings
#passwordSettings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'password'" *ngIf="(algorithm$ | async)?.id === 'password'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
@ -42,6 +45,7 @@
(onUpdated)="generate('password settings')" (onUpdated)="generate('password settings')"
/> />
<tools-passphrase-settings <tools-passphrase-settings
#passphraseSettings
class="tw-mt-6" class="tw-mt-6"
*ngIf="(algorithm$ | async)?.id === 'passphrase'" *ngIf="(algorithm$ | async)?.id === 'passphrase'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"

View File

@ -22,11 +22,11 @@ import { Option } from "@bitwarden/components/src/select/option";
import { import {
CredentialGeneratorService, CredentialGeneratorService,
Generators, Generators,
PasswordAlgorithm,
GeneratedCredential, GeneratedCredential,
CredentialAlgorithm, CredentialAlgorithm,
isPasswordAlgorithm, isPasswordAlgorithm,
AlgorithmInfo, AlgorithmInfo,
isSameAlgorithm,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorHistoryService } from "@bitwarden/generator-history";
@ -57,7 +57,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
@Input({ transform: coerceBooleanProperty }) disableMargin = false; @Input({ transform: coerceBooleanProperty }) disableMargin = false;
/** tracks the currently selected credential type */ /** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null); protected credentialType$ = new BehaviorSubject<CredentialAlgorithm>(null);
/** Emits the last generated value. */ /** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>(""); protected readonly value$ = new BehaviorSubject<string>("");
@ -72,14 +72,14 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next(requestor);
} }
/** Tracks changes to the selected credential type /** Tracks changes to the selected credential type
* @param type the new credential type * @param type the new credential type
*/ */
protected onCredentialTypeChanged(type: PasswordAlgorithm) { protected onCredentialTypeChanged(type: CredentialAlgorithm) {
// break subscription cycle // break subscription cycle
if (this.credentialType$.value !== type) { if (this.credentialType$.value !== type) {
this.zone.run(() => { this.zone.run(() => {
@ -169,29 +169,34 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
preferences.next(preference); preferences.next(preference);
}); });
// populate the form with the user's preferences to kick off interactivity // update active algorithm
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => { preferences
// update navigation
this.onCredentialTypeChanged(password.algorithm);
// load algorithm metadata
const algorithm = this.generatorService.algorithm(password.algorithm);
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.algorithm$.next(algorithm);
});
});
// generate on load unless the generator prohibits it
this.algorithm$
.pipe( .pipe(
distinctUntilChanged((prev, next) => prev.id === next.id), map(({ password }) => this.generatorService.algorithm(password.algorithm)),
filter((a) => !a.onlyOnRequest), distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
takeUntil(this.destroyed), takeUntil(this.destroyed),
) )
.subscribe(() => this.generate("autogenerate")); .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);
});
});
// generate on load unless the generator prohibits it
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) { private typeToGenerator$(type: CredentialAlgorithm) {

View File

@ -7,7 +7,7 @@
<bit-card> <bit-card>
<bit-form-field disableMargin> <bit-form-field disableMargin>
<bit-label>{{ "length" | i18n }}</bit-label> <bit-label>{{ "length" | i18n }}</bit-label>
<input bitInput formControlName="length" type="number" /> <input bitInput formControlName="length" type="number" (change)="save('length')" />
<bit-hint>{{ lengthBoundariesHint$ | async }}</bit-hint> <bit-hint>{{ lengthBoundariesHint$ | async }}</bit-hint>
</bit-form-field> </bit-form-field>
</bit-card> </bit-card>
@ -21,7 +21,12 @@
attr.aria-description="{{ 'uppercaseDescription' | i18n }}" attr.aria-description="{{ 'uppercaseDescription' | i18n }}"
title="{{ 'uppercaseDescription' | i18n }}" title="{{ 'uppercaseDescription' | i18n }}"
> >
<input bitCheckbox type="checkbox" formControlName="uppercase" /> <input
bitCheckbox
type="checkbox"
formControlName="uppercase"
(change)="save('uppercase')"
/>
<bit-label>{{ "uppercaseLabel" | i18n }}</bit-label> <bit-label>{{ "uppercaseLabel" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<bit-form-control <bit-form-control
@ -29,7 +34,12 @@
attr.aria-description="{{ 'lowercaseDescription' | i18n }}" attr.aria-description="{{ 'lowercaseDescription' | i18n }}"
title="{{ 'lowercaseDescription' | i18n }}" title="{{ 'lowercaseDescription' | i18n }}"
> >
<input bitCheckbox type="checkbox" formControlName="lowercase" /> <input
bitCheckbox
type="checkbox"
formControlName="lowercase"
(change)="save('lowercase')"
/>
<bit-label>{{ "lowercaseLabel" | i18n }}</bit-label> <bit-label>{{ "lowercaseLabel" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<bit-form-control <bit-form-control
@ -37,7 +47,7 @@
attr.aria-description="{{ 'numbersDescription' | i18n }}" attr.aria-description="{{ 'numbersDescription' | i18n }}"
title="{{ 'numbersDescription' | i18n }}" title="{{ 'numbersDescription' | i18n }}"
> >
<input bitCheckbox type="checkbox" formControlName="number" /> <input bitCheckbox type="checkbox" formControlName="number" (change)="save('number')" />
<bit-label>{{ "numbersLabel" | i18n }}</bit-label> <bit-label>{{ "numbersLabel" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<bit-form-control <bit-form-control
@ -45,22 +55,42 @@
attr.aria-description="{{ 'specialCharactersDescription' | i18n }}" attr.aria-description="{{ 'specialCharactersDescription' | i18n }}"
title="{{ 'specialCharactersDescription' | i18n }}" title="{{ 'specialCharactersDescription' | i18n }}"
> >
<input bitCheckbox type="checkbox" formControlName="special" /> <input
bitCheckbox
type="checkbox"
formControlName="special"
(change)="save('special')"
/>
<bit-label>{{ "specialCharactersLabel" | i18n }}</bit-label> <bit-label>{{ "specialCharactersLabel" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
</div> </div>
<div class="tw-flex"> <div class="tw-flex">
<bit-form-field class="tw-w-full tw-basis-1/2 tw-mr-4"> <bit-form-field class="tw-w-full tw-basis-1/2 tw-mr-4">
<bit-label>{{ "minNumbers" | i18n }}</bit-label> <bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input bitInput type="number" formControlName="minNumber" /> <input
bitInput
type="number"
formControlName="minNumber"
(change)="save('minNumbers')"
/>
</bit-form-field> </bit-form-field>
<bit-form-field class="tw-w-full tw-basis-1/2"> <bit-form-field class="tw-w-full tw-basis-1/2">
<bit-label>{{ "minSpecial" | i18n }}</bit-label> <bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input bitInput type="number" formControlName="minSpecial" /> <input
bitInput
type="number"
formControlName="minSpecial"
(change)="save('minSpecial')"
/>
</bit-form-field> </bit-form-field>
</div> </div>
<bit-form-control [disableMargin]="!policyInEffect"> <bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" /> <input
bitCheckbox
type="checkbox"
formControlName="avoidAmbiguous"
(change)="save('avoidAmbiguous')"
/>
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label> <bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> <p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>

View File

@ -1,7 +1,17 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -12,7 +22,7 @@ import {
PasswordGenerationOptions, PasswordGenerationOptions,
} from "@bitwarden/generator-core"; } from "@bitwarden/generator-core";
import { completeOnAccountSwitch, toValidators } from "./util"; import { completeOnAccountSwitch } from "./util";
const Controls = Object.freeze({ const Controls = Object.freeze({
length: "length", length: "length",
@ -118,23 +128,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
this.settings.patchValue(s, { emitEvent: false }); this.settings.patchValue(s, { emitEvent: false });
}); });
// bind policy to the template // explain policy & disable policy-overridden fields
this.generatorService this.generatorService
.policy$(Generators.password, { userId$: singleUserId$ }) .policy$(Generators.password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$)) .pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => { .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; this.policyInEffect = constraints.policyInEffect;
const toggles = [ const toggles = [
@ -153,8 +151,8 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
const boundariesHint = this.i18nService.t( const boundariesHint = this.i18nService.t(
"generatorBoundariesHint", "generatorBoundariesHint",
constraints.length.min, constraints.length.min?.toString(),
constraints.length.max, constraints.length.max?.toString(),
); );
this.lengthBoundariesHint.next(boundariesHint); this.lengthBoundariesHint.next(boundariesHint);
}); });
@ -201,9 +199,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// now that outputs are set up, connect inputs // now that outputs are set up, connect inputs
this.settings.valueChanges this.saveSettings
.pipe( .pipe(
map((settings) => { withLatestFrom(this.settings.valueChanges),
map(([, settings]) => {
// interface is "avoid" while storage is "include" // interface is "avoid" while storage is "include"
const s: any = { ...settings }; const s: any = { ...settings };
s.ambiguous = s.avoidAmbiguous; s.ambiguous = s.avoidAmbiguous;
@ -215,6 +214,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
.subscribe(settings); .subscribe(settings);
} }
private saveSettings = new Subject<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
}
/** display binding for enterprise policy notice */ /** display binding for enterprise policy notice */
protected policyInEffect: boolean; protected policyInEffect: boolean;
@ -246,6 +250,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -1,6 +1,11 @@
<form class="box" [formGroup]="settings" class="tw-container"> <form class="box" [formGroup]="settings" class="tw-container">
<bit-form-field> <bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label> <bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput formControlName="subaddressEmail" type="text" /> <input
bitInput
formControlName="subaddressEmail"
type="text"
(change)="save('subaddressEmail')"
/>
</bit-form-field> </bit-form-field>
</form> </form>

View File

@ -53,28 +53,25 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
const singleUserId$ = this.singleUserId$(); const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ }); const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ });
settings settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => {
.pipe( this.settings.patchValue(s, { emitEvent: false });
withLatestFrom(this.accountService.activeAccount$), });
map(([settings, activeAccount]) => {
// if the subaddress isn't specified, copy it from
// the user's settings
if ((settings.subaddressEmail ?? "").length < 1) {
settings.subaddressEmail = activeAccount.email;
}
return settings;
}),
takeUntil(this.destroyed$),
)
.subscribe((s) => {
this.settings.patchValue(s, { emitEvent: false });
});
// the first emission is the current value; subsequent emissions are updates // the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); 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<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
} }
private singleUserId$() { private singleUserId$() {
@ -92,6 +89,7 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -9,6 +9,7 @@
buttonType="main" buttonType="main"
(click)="generate('user request')" (click)="generate('user request')"
[appA11yTitle]="credentialTypeGenerateLabel$ | async" [appA11yTitle]="credentialTypeGenerateLabel$ | async"
[disabled]="!(algorithm$ | async)"
> >
{{ credentialTypeGenerateLabel$ | async }} {{ credentialTypeGenerateLabel$ | async }}
</button> </button>
@ -20,6 +21,7 @@
[appA11yTitle]="credentialTypeCopyLabel$ | async" [appA11yTitle]="credentialTypeCopyLabel$ | async"
[appCopyClick]="value$ | async" [appCopyClick]="value$ | async"
[valueLabel]="credentialTypeLabel$ | async" [valueLabel]="credentialTypeLabel$ | async"
[disabled]="!(algorithm$ | async)"
> >
{{ credentialTypeCopyLabel$ | async }} {{ credentialTypeCopyLabel$ | async }}
</button> </button>
@ -57,21 +59,25 @@
</bit-form-field> </bit-form-field>
</form> </form>
<tools-catchall-settings <tools-catchall-settings
#catchallSettings
*ngIf="(algorithm$ | async)?.id === 'catchall'" *ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate('catchall settings')" (onUpdated)="generate('catchall settings')"
/> />
<tools-forwarder-settings <tools-forwarder-settings
#forwarderSettings
*ngIf="!!(forwarderId$ | async)" *ngIf="!!(forwarderId$ | async)"
[forwarder]="forwarderId$ | async" [forwarder]="forwarderId$ | async"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
/> />
<tools-subaddress-settings <tools-subaddress-settings
#subaddressSettings
*ngIf="(algorithm$ | async)?.id === 'subaddress'" *ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate('subaddress settings')" (onUpdated)="generate('subaddress settings')"
/> />
<tools-username-settings <tools-username-settings
#usernameSettings
*ngIf="(algorithm$ | async)?.id === 'username'" *ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="this.userId$ | async" [userId]="this.userId$ | async"
(onUpdated)="generate('username settings')" (onUpdated)="generate('username settings')"

View File

@ -322,7 +322,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
if (!a || a.onlyOnRequest) { if (!a || a.onlyOnRequest) {
this.value$.next("-"); this.value$.next("-");
} else { } else {
this.generate("autogenerate"); this.generate("autogenerate").catch((e: unknown) => this.logService.error(e));
} }
}); });
}); });
@ -414,7 +414,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
* @param requestor a label used to trace generation request * @param requestor a label used to trace generation request
* origin in the debugger. * origin in the debugger.
*/ */
protected generate(requestor: string) { protected async generate(requestor: string) {
this.generate$.next(requestor); this.generate$.next(requestor);
} }
@ -429,6 +429,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
private readonly destroyed = new Subject<void>(); private readonly destroyed = new Subject<void>();
ngOnDestroy() { ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete(); this.destroyed.complete();
// finalize subjects // finalize subjects

View File

@ -1,10 +1,20 @@
<form class="box" [formGroup]="settings" class="tw-container"> <form class="box" [formGroup]="settings" class="tw-container">
<bit-form-control> <bit-form-control>
<input bitCheckbox formControlName="wordCapitalize" type="checkbox" /> <input
bitCheckbox
formControlName="wordCapitalize"
type="checkbox"
(change)="save('wordCapitalize')"
/>
<bit-label>{{ "capitalize" | i18n }}</bit-label> <bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<bit-form-control> <bit-form-control>
<input bitCheckbox formControlName="wordIncludeNumber" type="checkbox" /> <input
bitCheckbox
formControlName="wordIncludeNumber"
type="checkbox"
(change)="save('wordIncludeNumber')"
/>
<bit-label>{{ "includeNumber" | i18n }}</bit-label> <bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
</form> </form>

View File

@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid"; 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 // the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); 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<string>();
save(site: string = "component api call") {
this.saveSettings.next(site);
} }
private singleUserId$() { private singleUserId$() {
@ -79,6 +90,7 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>(); private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete(); this.destroyed$.complete();
} }
} }

View File

@ -49,7 +49,7 @@ export function toValidators<Policy, Settings>(
} }
const max = getConstraint("max", config, runtime); const max = getConstraint("max", config, runtime);
if (max === undefined) { if (max !== undefined) {
validators.push(Validators.max(max)); validators.push(Validators.max(max));
} }

View File

@ -1,7 +1,10 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; 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 { 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 { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { import {
EmailRandomizer, EmailRandomizer,
@ -19,12 +22,12 @@ import {
PasswordGeneratorOptionsEvaluator, PasswordGeneratorOptionsEvaluator,
passwordLeastPrivilege, passwordLeastPrivilege,
} from "../policies"; } from "../policies";
import { CatchallConstraints } from "../policies/catchall-constraints";
import { SubaddressConstraints } from "../policies/subaddress-constraints";
import { import {
CATCHALL_SETTINGS,
EFF_USERNAME_SETTINGS, EFF_USERNAME_SETTINGS,
PASSPHRASE_SETTINGS, PASSPHRASE_SETTINGS,
PASSWORD_SETTINGS, PASSWORD_SETTINGS,
SUBADDRESS_SETTINGS,
} from "../strategies/storage"; } from "../strategies/storage";
import { import {
CatchallGenerationOptions, CatchallGenerationOptions,
@ -178,79 +181,115 @@ const USERNAME = Object.freeze({
}, },
} satisfies CredentialGeneratorConfiguration<EffUsernameGenerationOptions, NoPolicy>); } satisfies CredentialGeneratorConfiguration<EffUsernameGenerationOptions, NoPolicy>);
const CATCHALL = Object.freeze({ const CATCHALL: CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPolicy> =
id: "catchall", Object.freeze({
category: "email", id: "catchall",
nameKey: "catchallEmail", category: "email",
descriptionKey: "catchallEmailDesc", nameKey: "catchallEmail",
generateKey: "generateEmail", descriptionKey: "catchallEmailDesc",
generatedValueKey: "email", generateKey: "generateEmail",
copyKey: "copyEmail", generatedValueKey: "email",
onlyOnRequest: false, copyKey: "copyEmail",
request: [], onlyOnRequest: false,
engine: { request: [],
create( engine: {
dependencies: GeneratorDependencyProvider, create(
): CredentialGenerator<CatchallGenerationOptions> { dependencies: GeneratorDependencyProvider,
return new EmailRandomizer(dependencies.randomizer); ): CredentialGenerator<CatchallGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
}, },
}, settings: {
settings: { initial: DefaultCatchallOptions,
initial: DefaultCatchallOptions, constraints: { catchallDomain: { minLength: 1 } },
constraints: { catchallDomain: { minLength: 1 } }, account: {
account: CATCHALL_SETTINGS, key: "catchallGeneratorSettings",
}, target: "object",
policy: { format: "plain",
type: PolicyType.PasswordGenerator, classifier: new PublicClassifier<CatchallGenerationOptions>([
disabledValue: {}, "catchallType",
combine(_acc: NoPolicy, _policy: Policy) { "catchallDomain",
return {}; ]),
state: GENERATOR_DISK,
initial: {
catchallType: "random",
catchallDomain: "",
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<CatchallGenerationOptions>,
}, },
createEvaluator(_policy: NoPolicy) { policy: {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>(); type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
},
toConstraints(_policy: NoPolicy, email: string) {
return new CatchallConstraints(email);
},
}, },
toConstraints(_policy: NoPolicy) { });
return new IdentityConstraint<CatchallGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<CatchallGenerationOptions, NoPolicy>);
const SUBADDRESS = Object.freeze({ const SUBADDRESS: CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy> =
id: "subaddress", Object.freeze({
category: "email", id: "subaddress",
nameKey: "plusAddressedEmail", category: "email",
descriptionKey: "plusAddressedEmailDesc", nameKey: "plusAddressedEmail",
generateKey: "generateEmail", descriptionKey: "plusAddressedEmailDesc",
generatedValueKey: "email", generateKey: "generateEmail",
copyKey: "copyEmail", generatedValueKey: "email",
onlyOnRequest: false, copyKey: "copyEmail",
request: [], onlyOnRequest: false,
engine: { request: [],
create( engine: {
dependencies: GeneratorDependencyProvider, create(
): CredentialGenerator<SubaddressGenerationOptions> { dependencies: GeneratorDependencyProvider,
return new EmailRandomizer(dependencies.randomizer); ): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
}, },
}, settings: {
settings: { initial: DefaultSubaddressOptions,
initial: DefaultSubaddressOptions, constraints: {},
constraints: {}, account: {
account: SUBADDRESS_SETTINGS, key: "subaddressGeneratorSettings",
}, target: "object",
policy: { format: "plain",
type: PolicyType.PasswordGenerator, classifier: new PublicClassifier<SubaddressGenerationOptions>([
disabledValue: {}, "subaddressType",
combine(_acc: NoPolicy, _policy: Policy) { "subaddressEmail",
return {}; ]),
state: GENERATOR_DISK,
initial: {
subaddressType: "random",
subaddressEmail: "",
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<SubaddressGenerationOptions>,
}, },
createEvaluator(_policy: NoPolicy) { policy: {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>(); type: PolicyType.PasswordGenerator,
disabledValue: {},
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
createEvaluator(_policy: NoPolicy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
},
toConstraints(_policy: NoPolicy, email: string) {
return new SubaddressConstraints(email);
},
}, },
toConstraints(_policy: NoPolicy) { });
return new IdentityConstraint<SubaddressGenerationOptions>();
},
},
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>( export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>(
configuration: ForwarderConfiguration<Settings>, configuration: ForwarderConfiguration<Settings>,

View File

@ -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("[^@]+@(?<domain>.+)");
/** A constraint that sets the catchall domain using a fixed email address */
export class CatchallConstraints implements StateConstraints<CatchallGenerationOptions> {
/** 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<Constraints<CatchallGenerationOptions>> = {};
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;
}
}

View File

@ -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<SubaddressGenerationOptions> {
/** Creates a catchall constraints
* @param email - the email address containing the domain.
*/
constructor(readonly email: string) {
if (!email) {
this.email = "";
}
}
constraints: Readonly<Constraints<SubaddressGenerationOptions>> = {};
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;
}
}

View File

@ -23,11 +23,12 @@ export function mapPolicyToEvaluator<Policy, Evaluator>(
*/ */
export function mapPolicyToConstraints<Policy, Evaluator>( export function mapPolicyToConstraints<Policy, Evaluator>(
configuration: PolicyConfiguration<Policy, Evaluator>, configuration: PolicyConfiguration<Policy, Evaluator>,
email: string,
) { ) {
return pipe( return pipe(
reduceCollection(configuration.combine, configuration.disabledValue), reduceCollection(configuration.combine, configuration.disabledValue),
distinctIfShallowMatch(), distinctIfShallowMatch(),
map(configuration.toConstraints), map((policy) => configuration.toConstraints(policy, email)),
); );
} }

View File

@ -202,6 +202,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@ -223,6 +224,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@ -248,6 +250,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
@ -276,6 +279,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
@ -297,6 +301,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
let error = null; let error = null;
@ -322,6 +327,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const website$ = new BehaviorSubject("some website"); const website$ = new BehaviorSubject("some website");
let completed = false; let completed = false;
@ -348,6 +354,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
@ -368,6 +375,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.pipe(filter((u) => !!u)); const userId$ = userId.pipe(filter((u) => !!u));
@ -392,6 +400,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let error = null; let error = null;
@ -417,6 +426,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser); const userId$ = new BehaviorSubject(SomeUser);
let completed = false; let completed = false;
@ -443,6 +453,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const on$ = new Subject<void>(); const on$ = new Subject<void>();
const results: any[] = []; const results: any[] = [];
@ -485,6 +496,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const on$ = new Subject<void>(); const on$ = new Subject<void>();
let error: any = null; let error: any = null;
@ -511,6 +523,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const on$ = new Subject<void>(); const on$ = new Subject<void>();
let complete = false; let complete = false;
@ -542,6 +555,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = generator.algorithms("password"); const result = generator.algorithms("password");
@ -563,6 +577,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = generator.algorithms("username"); const result = generator.algorithms("username");
@ -583,6 +598,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = generator.algorithms("email"); const result = generator.algorithms("email");
@ -604,6 +620,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = generator.algorithms(["username", "email"]); const result = generator.algorithms(["username", "email"]);
@ -629,6 +646,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("password")); const result = await firstValueFrom(generator.algorithms$("password"));
@ -646,6 +664,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("username")); const result = await firstValueFrom(generator.algorithms$("username"));
@ -662,6 +681,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$("email")); const result = await firstValueFrom(generator.algorithms$("email"));
@ -679,6 +699,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$(["username", "email"])); const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
@ -701,6 +722,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.algorithms$(["password"])); const result = await firstValueFrom(generator.algorithms$(["password"]));
@ -726,6 +748,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const results: any = []; const results: any = [];
const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
@ -763,6 +786,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
@ -784,6 +808,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -814,6 +839,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -840,6 +866,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -866,6 +893,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -898,6 +926,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -916,6 +945,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -936,6 +966,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const result = await firstValueFrom(generator.settings$(SomeConfiguration)); const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -961,6 +992,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const results: any = []; const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
@ -986,6 +1018,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const userId$ = new BehaviorSubject(AnotherUser).asObservable();
@ -1007,6 +1040,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1034,6 +1068,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1060,6 +1095,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1086,6 +1122,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1118,6 +1155,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
@ -1139,6 +1177,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
let completed = false; let completed = false;
@ -1165,6 +1204,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const userId$ = new BehaviorSubject(SomeUser).asObservable();
@ -1182,6 +1222,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId$ = new BehaviorSubject(SomeUser).asObservable(); const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]); const policy$ = new BehaviorSubject([somePolicy]);
@ -1201,6 +1242,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1230,6 +1272,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1260,6 +1303,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();
@ -1286,6 +1330,7 @@ describe("CredentialGeneratorService", () => {
i18nService, i18nService,
encryptService, encryptService,
keyService, keyService,
accountService,
); );
const userId = new BehaviorSubject(SomeUser); const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable(); const userId$ = userId.asObservable();

View File

@ -23,6 +23,7 @@ import { Simplify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; 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 { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
@ -98,6 +99,7 @@ export class CredentialGeneratorService {
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly encryptService: EncryptService, private readonly encryptService: EncryptService,
private readonly keyService: KeyService, private readonly keyService: KeyService,
private readonly accountService: AccountService,
) {} ) {}
private getDependencyProvider(): GeneratorDependencyProvider { private getDependencyProvider(): GeneratorDependencyProvider {
@ -380,17 +382,30 @@ export class CredentialGeneratorService {
configuration: Configuration<Settings, Policy>, configuration: Configuration<Settings, Policy>,
dependencies: Policy$Dependencies, dependencies: Policy$Dependencies,
): Observable<GeneratorConstraints<Settings>> { ): Observable<GeneratorConstraints<Settings>> {
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 };
}
const constraints$ = dependencies.userId$.pipe( return { userId, email: null };
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 const policies$ = this.policyService
.getAll$(configuration.policy.type, userId) .getAll$(configuration.policy.type, userId)
.pipe(takeUntil(completion$)); .pipe(
mapPolicyToConstraints(configuration.policy, email),
takeUntil(anyComplete(email$)),
);
return policies$; return policies$;
}), }),
mapPolicyToConstraints(configuration.policy),
); );
return constraints$; return constraints$;

View File

@ -24,9 +24,13 @@ export type PolicyConfiguration<Policy, Settings> = {
createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>; createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>;
/** Converts policy service data into actionable policy constraints. /** 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; * @remarks this version includes constraints needed for the reactive forms;
* it was introduced so that the constraints can be incrementally introduced * it was introduced so that the constraints can be incrementally introduced
* as the new UI is built. * as the new UI is built.
*/ */
toConstraints: (policy: Policy) => GeneratorConstraints<Settings>; toConstraints: (policy: Policy, email: string) => GeneratorConstraints<Settings>;
}; };

View File

@ -4,6 +4,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state"; import { StateProvider } from "@bitwarden/common/platform/state";
@ -43,6 +44,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
I18nService, I18nService,
EncryptService, EncryptService,
KeyService, KeyService,
AccountService,
], ],
}), }),
], ],