From 1a5dae51d39d33513dbf5accec72ab4d37bfdc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 16 Oct 2024 16:39:39 -0400 Subject: [PATCH] add forwarder settings to credential generator --- .../state/{subject-key.ts => object-key.ts} | 0 .../src/tools/state/user-state-subject.ts | 2 +- .../src/credential-generator.component.html | 5 + .../src/credential-generator.component.ts | 9 ++ .../src/forwarder-settings.component.html | 15 ++ .../src/forwarder-settings.component.ts | 132 ++++++++++++++++++ .../components/src/generator.module.ts | 6 +- .../src/username-generator.component.html | 6 +- .../src/username-generator.component.ts | 9 ++ .../generator/core/src/data/generators.ts | 8 +- .../src/engine/forwarder-configuration.ts | 19 ++- .../generator/core/src/integration/addy-io.ts | 6 + .../core/src/integration/duck-duck-go.ts | 4 + .../core/src/integration/fastmail.ts | 6 + .../core/src/integration/firefox-relay.ts | 4 + .../core/src/integration/forward-email.ts | 5 + .../core/src/integration/simple-login.ts | 4 + .../services/credential-generator.service.ts | 1 + .../credential-generator-configuration.ts | 10 ++ 19 files changed, 244 insertions(+), 7 deletions(-) rename libs/common/src/tools/state/{subject-key.ts => object-key.ts} (100%) create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.html create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.ts diff --git a/libs/common/src/tools/state/subject-key.ts b/libs/common/src/tools/state/object-key.ts similarity index 100% rename from libs/common/src/tools/state/subject-key.ts rename to libs/common/src/tools/state/object-key.ts diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 4e6152eb91..091d5cfa21 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -37,8 +37,8 @@ import { Constraints, SubjectConstraints, WithConstraints } from "../types"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; import { unconstrained$ } from "./identity-state-constraint"; +import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; -import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./subject-key"; import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index ea337a16ee..3de9f35e00 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -68,6 +68,11 @@ [userId]="userId$ | async" (onUpdated)="generate$.next()" /> + { this.algorithm$.next(algorithm); + if (userNav === FORWARDER && forwarderNav !== NONE_SELECTED) { + this.forwarderId$.next(forwarderNav.forwarder); + } else { + this.forwarderId$.next(null); + } }); }); @@ -314,6 +320,9 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Lists the credential types of the username algorithm box. */ protected forwarderOptions$ = new BehaviorSubject[]>([]); + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + /** tracks the currently selected credential type */ protected algorithm$ = new ReplaySubject(1); diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html new file mode 100644 index 0000000000..a657abc287 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -0,0 +1,15 @@ +
+ + {{ "aliasDomain" | i18n }} + + + + {{ "apiAccessToken" | i18n }} + + + + + {{ "baseUrl" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts new file mode 100644 index 0000000000..e5b8aaad35 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -0,0 +1,132 @@ +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 { IntegrationId } from "@bitwarden/common/tools/integration"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorService, + getForwarderConfiguration, + toCredentialGeneratorConfiguration, +} from "@bitwarden/generator-core"; + +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + domain: "domain", + token: "token", + baseUrl: "baseUrl", +}); + +/** Options group for forwarder integrations */ +@Component({ + selector: "tools-forwarder-settings", + templateUrl: "forwarder-settings.component.html", +}) +export class ForwarderSettingsComponent 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; + + @Input() + forwarder: IntegrationId; + + /** 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(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + [Controls.domain]: [""], + [Controls.token]: [""], + [Controls.baseUrl]: [""], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const forwarder = getForwarderConfiguration(this.forwarder); + + // type erasure necessary because the configuration properties are + // determined dynamically at runtime + // FIXME: this can be eliminated by unifying the forwarder settings types; + // see `ForwarderConfiguration<...>` for details. + const configuration = toCredentialGeneratorConfiguration(forwarder); + this.displayDomain = configuration.request.includes("domain"); + this.displayToken = configuration.request.includes("token"); + this.displayBaseUrl = configuration.request.includes("baseUrl"); + + // bind settings to the UI + const settings = await this.generatorService.settings(configuration, { singleUserId$ }); + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + // skips reactive event emissions to break a subscription cycle + this.settings.patchValue(s, { emitEvent: false }); + }); + + // bind policy to the template + this.generatorService + .policy$(configuration, { userId$: singleUserId$ }) + .pipe(takeUntil(this.destroyed$)) + .subscribe(({ constraints }) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (configuration.request.includes(control as any)) { + control.enable({ emitEvent: false }); + control.setValidators( + // the configuration's type erasure affects `toValidators` as well + toValidators(name, configuration, constraints), + ); + } else { + control.disable({ emitEvent: false }); + control.clearValidators(); + } + } + }); + + // the first emission is the current value; subsequent emissions are updates + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + + // now that outputs are set up, connect inputs + this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + protected displayDomain: boolean; + protected displayToken: boolean; + protected displayBaseUrl: boolean; + + 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(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 59b7bc898e..58117bec49 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -33,6 +33,7 @@ import { import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordSettingsComponent } from "./password-settings.component"; @@ -84,12 +85,13 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); declarations: [ CatchallSettingsComponent, CredentialGeneratorComponent, + ForwarderSettingsComponent, SubaddressSettingsComponent, - UsernameSettingsComponent, PasswordGeneratorComponent, - PasswordSettingsComponent, PassphraseSettingsComponent, + PasswordSettingsComponent, UsernameGeneratorComponent, + UsernameSettingsComponent, ], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 92202833f0..f57fd6bb20 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -43,6 +43,11 @@ [userId]="this.userId$ | async" (onUpdated)="generate$.next()" /> + - diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 246886ead2..d6725466c7 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -16,6 +16,7 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; import { UserId } from "@bitwarden/common/types/guid"; import { Option } from "@bitwarden/components/src/select/option"; import { @@ -201,6 +202,11 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // template bindings refresh immediately this.zone.run(() => { this.algorithm$.next(algorithm); + if (userNav === FORWARDER && forwarderNav !== NONE_SELECTED) { + this.forwarderId$.next(forwarderNav.forwarder); + } else { + this.forwarderId$.next(null); + } }); }); @@ -249,6 +255,9 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** tracks the currently selected credential type */ protected algorithm$ = new ReplaySubject(1); + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 638d0d2b95..8974c05cf8 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -53,6 +53,7 @@ const PASSPHRASE = Object.freeze({ category: "password", nameKey: "passphrase", onlyOnRequest: false, + request: [], engine: { create( dependencies: GeneratorDependencyProvider, @@ -92,6 +93,7 @@ const PASSWORD = Object.freeze({ category: "password", nameKey: "password", onlyOnRequest: false, + request: [], engine: { create( dependencies: GeneratorDependencyProvider, @@ -139,6 +141,7 @@ const USERNAME = Object.freeze({ category: "username", nameKey: "randomWord", onlyOnRequest: false, + request: [], engine: { create( dependencies: GeneratorDependencyProvider, @@ -172,6 +175,7 @@ const CATCHALL = Object.freeze({ nameKey: "catchallEmail", descriptionKey: "catchallEmailDesc", onlyOnRequest: false, + request: [], engine: { create( dependencies: GeneratorDependencyProvider, @@ -205,6 +209,7 @@ const SUBADDRESS = Object.freeze({ nameKey: "plusAddressedEmail", descriptionKey: "plusAddressedEmailDesc", onlyOnRequest: false, + request: [], engine: { create( dependencies: GeneratorDependencyProvider, @@ -240,6 +245,7 @@ export function toCredentialGeneratorConfiguration = RpcConfiguration, string>; +export type ForwarderRequestFields = keyof (ApiSettings & + SelfHostedApiSettings & + EmailDomainSettings & + EmailPrefixSettings); + /** Forwarder-specific static definition */ export type ForwarderConfiguration< + // FIXME: simply forwarder settings to an object that has all + // settings properties. The runtime dynamism should be limited + // to which have values, not which have properties listed. Settings extends ApiSettings, Request extends IntegrationRequest = IntegrationRequest, > = IntegrationConfiguration & { @@ -35,6 +45,11 @@ export type ForwarderConfiguration< /** default value of all fields */ defaultSettings: Partial; + settingsConstraints: Constraints; + + /** Well-known fields to display on the forwarder screen */ + request: readonly ForwarderRequestFields[]; + /** forwarder settings storage * @deprecated use local.settings instead */ diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 9b38ee2378..e4037fc870 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -52,6 +52,12 @@ const createForwardingEmail = Object.freeze({ const forwarder = Object.freeze({ defaultSettings, createForwardingEmail, + request: ["token", "baseUrl", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + baseUrl: {}, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index 9d8185e250..dad2aed514 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -44,6 +44,10 @@ const createForwardingEmail = Object.freeze({ const forwarder = Object.freeze({ defaultSettings, createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index f45357c1b5..e8fa3b53f5 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -110,6 +110,12 @@ const forwarder = Object.freeze({ defaultSettings, createForwardingEmail, getAccountId, + request: ["token", "domain", "prefix"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + prefix: {}, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index 853b07ecf5..5bba5e3f2a 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -48,6 +48,10 @@ const createForwardingEmail = Object.freeze({ const forwarder = Object.freeze({ defaultSettings, createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index f9263522de..e055488808 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -50,6 +50,11 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + request: ["token", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 2a25024152..1fb046df5d 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -53,6 +53,10 @@ const createForwardingEmail = Object.freeze({ const forwarder = Object.freeze({ defaultSettings, createForwardingEmail, + request: ["token", "baseUrl"], + settingsConstraints: { + token: { required: true }, + }, local: { settings: { // FIXME: integration should issue keys at runtime diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 21f51c67c1..412188a738 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -244,6 +244,7 @@ export class CredentialGeneratorService { category: generator.category, name: integration ? integration.name : this.i18nService.t(generator.nameKey), onlyOnRequest: generator.onlyOnRequest, + request: generator.request, }; if (generator.descriptionKey) { diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 22092bd71f..fc1de8bd21 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -40,6 +40,11 @@ export type AlgorithmInfo = { * to Bitwarden. */ onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; }; /** Credential generator metadata common across credential generators */ @@ -68,6 +73,11 @@ export type CredentialGeneratorInfo = { * to Bitwarden. */ onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; }; /** Credential generator metadata that relies upon typed setting and policy definitions.