1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-5609] passphrase settings component & services (#10535)

This commit is contained in:
✨ Audrey ✨
2024-08-28 09:26:17 -04:00
committed by GitHub
parent 819c312ce2
commit 8c4b8d71ea
34 changed files with 1147 additions and 144 deletions

View File

@@ -0,0 +1,48 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { StateProvider } from "@bitwarden/common/platform/state";
import {
CardComponent,
CheckboxModule,
ColorPasswordModule,
FormFieldModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
} from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
/** Shared module containing generator component dependencies */
@NgModule({
imports: [SectionComponent, SectionHeaderComponent, CardComponent],
exports: [
JslibModule,
JslibServicesModule,
FormFieldModule,
CommonModule,
ReactiveFormsModule,
ColorPasswordModule,
InputModule,
CheckboxModule,
SectionComponent,
SectionHeaderComponent,
CardComponent,
],
providers: [
safeProvider({
provide: CredentialGeneratorService,
useClass: CredentialGeneratorService,
deps: [StateProvider, PolicyService],
}),
],
declarations: [],
})
export class DependenciesModule {
constructor() {}
}

View File

@@ -0,0 +1 @@
export { PassphraseSettingsComponent } from "./passphrase-settings.component";

View File

@@ -0,0 +1,38 @@
<bit-section>
<bit-section-header *ngIf="showHeader">
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<form class="box" [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
<bit-form-field>
<bit-label>{{ "numWords" | i18n }}</bit-label>
<input
bitInput
formControlName="numWords"
id="num-words"
type="number"
[min]="minNumWords"
[max]="maxNumWords"
/>
</bit-form-field>
</bit-card>
</div>
<div>
<bit-card>
<bit-form-field>
<bit-label>{{ "wordSeparator" | i18n }}</bit-label>
<input bitInput formControlName="wordSeparator" id="word-separator" type="text" />
</bit-form-field>
<bit-form-control>
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</bit-card>
</div>
</form>
</bit-section>

View File

@@ -0,0 +1,139 @@
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, takeUntil, Subject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
Generators,
CredentialGeneratorService,
PassphraseGenerationOptions,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch, toValidators } from "./util";
const Controls = Object.freeze({
numWords: "numWords",
includeNumber: "includeNumber",
capitalize: "capitalize",
wordSeparator: "wordSeparator",
});
/** Options group for passphrases */
@Component({
standalone: true,
selector: "bit-passphrase-settings",
templateUrl: "passphrase-settings.component.html",
imports: [DependenciesModule],
})
export class PassphraseSettingsComponent implements OnInit, OnDestroy {
/** Instantiates the component
* @param accountService queries user availability
* @param generatorService settings and policy logic
* @param formBuilder reactive form controls
*/
constructor(
private formBuilder: FormBuilder,
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
) {}
/** Binds the passphrase component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */
@Input()
showHeader: boolean = true;
/** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like
* to receive live settings updates including the initial update,
* use `CredentialGeneratorService.settings$(...)` instead.
*/
@Output()
readonly onUpdated = new EventEmitter<PassphraseGenerationOptions>();
protected settings = this.formBuilder.group({
[Controls.numWords]: [Generators.Passphrase.settings.initial.numWords],
[Controls.wordSeparator]: [Generators.Passphrase.settings.initial.wordSeparator],
[Controls.capitalize]: [Generators.Passphrase.settings.initial.capitalize],
[Controls.includeNumber]: [Generators.Passphrase.settings.initial.includeNumber],
});
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.Passphrase, { singleUserId$ });
// skips reactive event emissions to break a subscription cycle
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);
// dynamic policy enforcement
this.generatorService
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe((policy) => {
this.settings
.get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy));
this.settings
.get(Controls.wordSeparator)
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, policy));
// forward word boundaries to the template (can't do it through the rx form)
// FIXME: move the boundary logic fully into the policy evaluator
this.minNumWords =
policy.numWords?.min ?? Generators.Passphrase.settings.constraints.numWords.min;
this.maxNumWords =
policy.numWords?.max ?? Generators.Passphrase.settings.constraints.numWords.max;
this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize);
this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber);
});
// now that outputs are set up, connect inputs
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
}
/** attribute binding for numWords[min] */
protected minNumWords: number;
/** attribute binding for numWords[max] */
protected maxNumWords: number;
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
if (enabled) {
this.settings.get(setting).enable();
} else {
this.settings.get(setting).disable();
}
}
private singleUserId$() {
// FIXME: this branch should probably scan for the user and make sure
// the account is unlocked
if (this.userId) {
return new BehaviorSubject(this.userId as UserId).asObservable();
}
return this.accountService.activeAccount$.pipe(
completeOnAccountSwitch(),
takeUntil(this.destroyed$),
);
}
private readonly destroyed$ = new Subject<void>();
ngOnDestroy(): void {
this.destroyed$.complete();
}
}

View File

@@ -0,0 +1,68 @@
import { ValidatorFn, Validators } from "@angular/forms";
import { map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs";
import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types";
import { UserId } from "@bitwarden/common/types/guid";
import { CredentialGeneratorConfiguration } from "@bitwarden/generator-core";
export function completeOnAccountSwitch() {
return pipe(
map(({ id }: { id: UserId | null }) => id),
skipWhile((id) => !id),
startWith(null as UserId),
pairwise(),
takeWhile(([prev, next]) => (prev ?? next) === next),
map(([_, id]) => id),
);
}
export function toValidators<Policy, Settings>(
target: keyof Settings,
configuration: CredentialGeneratorConfiguration<Settings, Policy>,
policy?: Constraints<Settings>,
) {
const validators: Array<ValidatorFn> = [];
// widen the types to avoid typecheck issues
const config: AnyConstraint = configuration.settings.constraints[target];
const runtime: AnyConstraint = policy[target];
const required = getConstraint("required", config, runtime) ?? false;
if (required) {
validators.push(Validators.required);
}
const maxLength = getConstraint("maxLength", config, runtime);
if (maxLength !== undefined) {
validators.push(Validators.maxLength(maxLength));
}
const minLength = getConstraint("minLength", config, runtime);
if (minLength !== undefined) {
validators.push(Validators.minLength(config.minLength));
}
const min = getConstraint("min", config, runtime);
if (min !== undefined) {
validators.push(Validators.min(min));
}
const max = getConstraint("max", config, runtime);
if (max === undefined) {
validators.push(Validators.max(max));
}
return validators;
}
function getConstraint<Key extends keyof AnyConstraint>(
key: Key,
config: AnyConstraint,
policy?: AnyConstraint,
) {
if (policy && key in policy) {
return policy[key] ?? config[key];
} else if (key in config) {
return config[key];
}
}