1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-5611] username generator panel (#11201)

* add username and email engines to generators
* introduce username and email settings components
* introduce generator algorithm metadata
* inline generator policies
* wait until settings are available during generation
This commit is contained in:
✨ Audrey ✨
2024-09-27 09:02:59 -04:00
committed by GitHub
parent f1ac1d44e3
commit 433ae13513
39 changed files with 1761 additions and 167 deletions

View File

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

View File

@@ -0,0 +1,86 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CatchallGenerationOptions,
CredentialGeneratorService,
Generators,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util";
/** Options group for catchall emails */
@Component({
standalone: true,
selector: "tools-catchall-settings",
templateUrl: "catchall-settings.component.html",
imports: [DependenciesModule],
})
export class CatchallSettingsComponent 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 component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** 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<CatchallGenerationOptions>();
/** The template's control bindings */
protected settings = this.formBuilder.group({
catchallDomain: [Generators.catchall.settings.initial.catchallDomain],
});
async ngOnInit() {
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 });
});
// 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);
}
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

@@ -19,6 +19,7 @@ import {
ItemModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
ToggleGroupModule,
} from "@bitwarden/components";
import {
@@ -46,6 +47,7 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
ToggleGroupModule,
],
providers: [

View File

@@ -1,5 +1,9 @@
export { PassphraseSettingsComponent } from "./passphrase-settings.component";
export { CatchallSettingsComponent } from "./catchall-settings.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { PassphraseSettingsComponent } from "./passphrase-settings.component";
export { PasswordSettingsComponent } from "./password-settings.component";
export { PasswordGeneratorComponent } from "./password-generator.component";
export { SubaddressSettingsComponent } from "./subaddress-settings.component";
export { UsernameGeneratorComponent } from "./username-generator.component";
export { UsernameSettingsComponent } from "./username-settings.component";

View File

@@ -39,7 +39,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
private accountService: AccountService,
) {}
/** Binds the passphrase component to a specific user's settings.
/** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@@ -59,15 +59,15 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
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],
[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$ });
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) => {
@@ -79,16 +79,16 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
// dynamic policy enforcement
this.generatorService
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
.policy$(Generators.passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => {
this.settings
.get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints));
.setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints));
this.settings
.get(Controls.wordSeparator)
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, constraints));
.setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints));
// forward word boundaries to the template (can't do it through the rx form)
this.minNumWords = constraints.numWords.min;

View File

@@ -3,8 +3,12 @@ import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUnt
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core";
import { GeneratedCredential } from "@bitwarden/generator-history";
import {
CredentialGeneratorService,
Generators,
PasswordAlgorithm,
GeneratedCredential,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
@@ -24,7 +28,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
private zone: NgZone,
) {}
/** Binds the passphrase component to a specific user's settings.
/** Binds the component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@@ -32,7 +36,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
userId: UserId | null;
/** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<GeneratorType>("password");
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>("password");
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
@@ -46,7 +50,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
/** Tracks changes to the selected credential type
* @param type the new credential type
*/
protected onCredentialTypeChanged(type: GeneratorType) {
protected onCredentialTypeChanged(type: PasswordAlgorithm) {
if (this.credentialType$.value !== type) {
this.credentialType$.next(type);
this.generate$.next();
@@ -85,7 +89,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
});
}
private typeToGenerator$(type: GeneratorType) {
private typeToGenerator$(type: PasswordAlgorithm) {
const dependencies = {
on$: this.generate$,
userId$: this.userId$,
@@ -93,10 +97,10 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
switch (type) {
case "password":
return this.generatorService.generate$(Generators.Password, dependencies);
return this.generatorService.generate$(Generators.password, dependencies);
case "passphrase":
return this.generatorService.generate$(Generators.Passphrase, dependencies);
return this.generatorService.generate$(Generators.passphrase, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
}

View File

@@ -67,14 +67,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
readonly onUpdated = new EventEmitter<PasswordGenerationOptions>();
protected settings = this.formBuilder.group({
[Controls.length]: [Generators.Password.settings.initial.length],
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase],
[Controls.lowercase]: [Generators.Password.settings.initial.lowercase],
[Controls.number]: [Generators.Password.settings.initial.number],
[Controls.special]: [Generators.Password.settings.initial.special],
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber],
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial],
[Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous],
[Controls.length]: [Generators.password.settings.initial.length],
[Controls.uppercase]: [Generators.password.settings.initial.uppercase],
[Controls.lowercase]: [Generators.password.settings.initial.lowercase],
[Controls.number]: [Generators.password.settings.initial.number],
[Controls.special]: [Generators.password.settings.initial.special],
[Controls.minNumber]: [Generators.password.settings.initial.minNumber],
[Controls.minSpecial]: [Generators.password.settings.initial.minSpecial],
[Controls.avoidAmbiguous]: [!Generators.password.settings.initial.ambiguous],
});
private get numbers() {
@@ -95,7 +95,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ });
const settings = await this.generatorService.settings(Generators.password, { singleUserId$ });
// bind settings to the UI
settings
@@ -116,19 +116,19 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
// bind policy to the template
this.generatorService
.policy$(Generators.Password, { userId$: singleUserId$ })
.policy$(Generators.password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe(({ constraints }) => {
this.settings
.get(Controls.length)
.setValidators(toValidators(Controls.length, Generators.Password, constraints));
.setValidators(toValidators(Controls.length, Generators.password, constraints));
this.minNumber.setValidators(
toValidators(Controls.minNumber, Generators.Password, constraints),
toValidators(Controls.minNumber, Generators.password, constraints),
);
this.minSpecial.setValidators(
toValidators(Controls.minSpecial, Generators.Password, constraints),
toValidators(Controls.minSpecial, Generators.password, constraints),
);
// forward word boundaries to the template (can't do it through the rx form)

View File

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

View File

@@ -0,0 +1,86 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CredentialGeneratorService,
Generators,
SubaddressGenerationOptions,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util";
/** Options group for plus-addressed emails */
@Component({
standalone: true,
selector: "tools-subaddress-settings",
templateUrl: "subaddress-settings.component.html",
imports: [DependenciesModule],
})
export class SubaddressSettingsComponent 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 component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** 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<SubaddressGenerationOptions>();
/** The template's control bindings */
protected settings = this.formBuilder.group({
subaddressEmail: [Generators.subaddress.settings.initial.subaddressEmail],
});
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ });
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);
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
}
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,51 @@
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow tw-flex tw-items-center">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
<button
type="button"
bitIconButton="bwi-clone"
buttonType="main"
[appCopyClick]="value$ | async"
>
{{ "copyPassword" | i18n }}
</button>
</div>
</bit-card>
<bit-section>
<bit-section-header>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<div class="tw-mb-4">
<bit-card>
<form class="box" [formGroup]="credential" class="tw-container">
<bit-form-field>
<bit-label>{{ "type" | i18n }}</bit-label>
<bit-select [items]="typeOptions$ | async" formControlName="type"> </bit-select>
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
credentialTypeHint$ | async
}}</bit-hint>
</bit-form-field>
</form>
<tools-catchall-settings
*ngIf="(algorithm$ | async)?.id === 'catchall'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-subaddress-settings
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<tools-username-settings
*ngIf="(algorithm$ | async)?.id === 'username'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
</bit-card>
</div>
</bit-section>

View File

@@ -0,0 +1,238 @@
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import {
BehaviorSubject,
distinctUntilChanged,
filter,
map,
ReplaySubject,
Subject,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { Option } from "@bitwarden/components/src/select/option";
import {
CredentialAlgorithm,
CredentialGeneratorInfo,
CredentialGeneratorService,
GeneratedCredential,
Generators,
isEmailAlgorithm,
isUsernameAlgorithm,
} from "@bitwarden/generator-core";
import { CatchallSettingsComponent } from "./catchall-settings.component";
import { DependenciesModule } from "./dependencies";
import { SubaddressSettingsComponent } from "./subaddress-settings.component";
import { UsernameSettingsComponent } from "./username-settings.component";
import { completeOnAccountSwitch } from "./util";
/** Component that generates usernames and emails */
@Component({
standalone: true,
selector: "tools-username-generator",
templateUrl: "username-generator.component.html",
imports: [
DependenciesModule,
CatchallSettingsComponent,
SubaddressSettingsComponent,
UsernameSettingsComponent,
],
})
export class UsernameGeneratorComponent implements OnInit, OnDestroy {
/** Instantiates the username generator
* @param generatorService generates credentials; stores preferences
* @param i18nService localizes generator algorithm descriptions
* @param accountService discovers the active user when one is not provided
* @param zone detects generator settings updates originating from the generator services
* @param formBuilder binds reactive form
*/
constructor(
private generatorService: CredentialGeneratorService,
private i18nService: I18nService,
private accountService: AccountService,
private zone: NgZone,
private formBuilder: FormBuilder,
) {}
/** Binds the component to a specific user's settings. When this input is not provided,
* the form binds to the active user
*/
@Input()
userId: UserId | null;
/** Emits credentials created from a generation request. */
@Output()
readonly onGenerated = new EventEmitter<GeneratedCredential>();
/** Tracks the selected generation algorithm */
protected credential = this.formBuilder.group({
type: ["username" as CredentialAlgorithm],
});
async ngOnInit() {
if (this.userId) {
this.userId$.next(this.userId);
} else {
this.singleUserId$().pipe(takeUntil(this.destroyed)).subscribe(this.userId$);
}
this.generatorService
.algorithms$(["email", "username"], { userId$: this.userId$ })
.pipe(
map((algorithms) => this.toOptions(algorithms)),
takeUntil(this.destroyed),
)
.subscribe(this.typeOptions$);
this.algorithm$
.pipe(
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)),
takeUntil(this.destroyed),
)
.subscribe((hint) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.credentialTypeHint$.next(hint);
});
});
// wire up the generator
this.algorithm$
.pipe(
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
takeUntil(this.destroyed),
)
.subscribe((generated) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.onGenerated.next(generated);
this.value$.next(generated.credential);
});
});
// assume the last-visible generator algorithm is the user's preferred one
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
this.credential.valueChanges
.pipe(withLatestFrom(preferences), takeUntil(this.destroyed))
.subscribe(([{ type }, preference]) => {
if (isEmailAlgorithm(type)) {
preference.email.algorithm = type;
preference.email.updated = new Date();
} else if (isUsernameAlgorithm(type)) {
preference.username.algorithm = type;
preference.username.updated = new Date();
} else {
return;
}
preferences.next(preference);
});
// populate the form with the user's preferences to kick off interactivity
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => {
// this generator supports email & username; the last preference
// set by the user "wins"
const preference = email.updated > username.updated ? email.algorithm : username.algorithm;
// break subscription loop
this.credential.setValue({ type: preference }, { emitEvent: false });
const algorithm = this.generatorService.algorithm(preference);
// 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(
distinctUntilChanged((prev, next) => prev.id === next.id),
filter((a) => !a.onlyOnRequest),
takeUntil(this.destroyed),
)
.subscribe(() => this.generate$.next());
}
private typeToGenerator$(type: CredentialAlgorithm) {
const dependencies = {
on$: this.generate$,
userId$: this.userId$,
};
switch (type) {
case "catchall":
return this.generatorService.generate$(Generators.catchall, dependencies);
case "subaddress":
return this.generatorService.generate$(Generators.subaddress, dependencies);
case "username":
return this.generatorService.generate$(Generators.username, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
}
}
/** Lists the credential types supported by the component. */
protected typeOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
/** tracks the currently selected credential type */
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
/** Emits hint key for the currently selected credential type */
protected credentialTypeHint$ = new ReplaySubject<string>(1);
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>();
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 toOptions(algorithms: CredentialGeneratorInfo[]) {
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
value: algorithm.id,
label: this.i18nService.t(algorithm.nameKey),
}));
return options;
}
private readonly destroyed = new Subject<void>();
ngOnDestroy() {
this.destroyed.complete();
// finalize subjects
this.generate$.complete();
this.value$.complete();
// finalize component bindings
this.onGenerated.complete();
}
}

View File

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

View File

@@ -0,0 +1,87 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
CredentialGeneratorService,
EffUsernameGenerationOptions,
Generators,
} from "@bitwarden/generator-core";
import { DependenciesModule } from "./dependencies";
import { completeOnAccountSwitch } from "./util";
/** Options group for usernames */
@Component({
standalone: true,
selector: "tools-username-settings",
templateUrl: "username-settings.component.html",
imports: [DependenciesModule],
})
export class UsernameSettingsComponent 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 component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** 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<EffUsernameGenerationOptions>();
/** The template's control bindings */
protected settings = this.formBuilder.group({
wordCapitalize: [Generators.username.settings.initial.wordCapitalize],
wordIncludeNumber: [Generators.username.settings.initial.wordIncludeNumber],
});
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.username, { singleUserId$ });
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);
this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings);
}
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();
}
}