diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index db1f960b9b3..49d7ae0f3a0 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1803,6 +1803,18 @@ "passwordGeneratorPolicyInEffect": { "message": "One or more organization policies are affecting your generator settings." }, + "passwordGenerator": { + "message": "Password generator" + }, + "usernameGenerator": { + "message": "Username generator" + }, + "useThisPassword": { + "message": "Use this password" + }, + "useThisUsername": { + "message": "Use this username" + }, "vaultTimeoutAction": { "message": "Vault timeout action" }, diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.html b/apps/browser/src/platform/popup/layout/popup-header.component.html index 82a2b715a0e..fefc7154314 100644 --- a/apps/browser/src/platform/popup/layout/popup-header.component.html +++ b/apps/browser/src/platform/popup/layout/popup-header.component.html @@ -14,7 +14,7 @@ type="button" *ngIf="showBackButton" [title]="'back' | i18n" - [ariaLabel]="'back' | i18n" + [attr.aria-label]="'back' | i18n" [bitAction]="backAction" >

diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index cae324fac1f..9d42d6b6040 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -14,6 +14,7 @@ import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/compo import { CipherFormConfig, CipherFormConfigService, + CipherFormGenerationService, CipherFormMode, CipherFormModule, DefaultCipherFormConfigService, @@ -27,6 +28,7 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service"; import { BrowserFido2UserInterfaceSession } from "../../../../fido2/browser-fido2-user-interface.service"; +import { BrowserCipherFormGenerationService } from "../../../services/browser-cipher-form-generation.service"; import { BrowserTotpCaptureService } from "../../../services/browser-totp-capture.service"; import { fido2PopoutSessionData$, @@ -106,6 +108,7 @@ export type AddEditQueryParams = Partial>; providers: [ { provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }, { provide: TotpCaptureService, useClass: BrowserTotpCaptureService }, + { provide: CipherFormGenerationService, useClass: BrowserCipherFormGenerationService }, ], imports: [ CommonModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html new file mode 100644 index 00000000000..7652b8ab0bf --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts new file mode 100644 index 00000000000..d25dfadf5bc --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -0,0 +1,82 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +import { + GeneratorDialogParams, + GeneratorDialogResult, + VaultGeneratorDialogComponent, +} from "./vault-generator-dialog.component"; + +@Component({ + selector: "vault-cipher-form-generator", + template: "", + standalone: true, +}) +class MockCipherFormGenerator { + @Input() type: "password" | "username"; + @Output() valueGenerated = new EventEmitter(); +} + +describe("VaultGeneratorDialogComponent", () => { + let component: VaultGeneratorDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: MockProxy>; + let dialogData: GeneratorDialogParams; + + beforeEach(async () => { + mockDialogRef = mock>(); + dialogData = { type: "password" }; + + await TestBed.configureTestingModule({ + imports: [VaultGeneratorDialogComponent, NoopAnimationsModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: DialogRef, useValue: mockDialogRef }, + ], + }) + .overrideComponent(VaultGeneratorDialogComponent, { + remove: { imports: [CipherFormGeneratorComponent] }, + add: { imports: [MockCipherFormGenerator] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VaultGeneratorDialogComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("should use the appropriate text based on generator type", () => { + expect(component["title"]).toBe("passwordGenerator"); + expect(component["selectButtonText"]).toBe("useThisPassword"); + + dialogData.type = "username"; + + fixture = TestBed.createComponent(VaultGeneratorDialogComponent); + component = fixture.componentInstance; + + expect(component["title"]).toBe("usernameGenerator"); + expect(component["selectButtonText"]).toBe("useThisUsername"); + }); + + it("should close the dialog with the generated value when the user selects it", () => { + component["generatedValue"] = "generated-value"; + + fixture.nativeElement.querySelector("button[data-testid='select-button']").click(); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + action: "selected", + generatedValue: "generated-value", + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts new file mode 100644 index 00000000000..657d126081f --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts @@ -0,0 +1,120 @@ +import { animate, group, style, transition, trigger } from "@angular/animations"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Overlay } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; + +export interface GeneratorDialogParams { + type: "password" | "username"; +} + +export interface GeneratorDialogResult { + action: GeneratorDialogAction; + generatedValue?: string; +} + +export enum GeneratorDialogAction { + Selected = "selected", + Canceled = "canceled", +} + +const slideIn = trigger("slideIn", [ + transition(":enter", [ + style({ opacity: 0, transform: "translateY(100vh)" }), + group([ + animate("0.15s linear", style({ opacity: 1 })), + animate("0.3s ease-out", style({ transform: "none" })), + ]), + ]), +]); + +@Component({ + selector: "app-vault-generator-dialog", + templateUrl: "./vault-generator-dialog.component.html", + standalone: true, + imports: [ + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + CommonModule, + CipherFormGeneratorComponent, + ButtonModule, + ], + animations: [slideIn], +}) +export class VaultGeneratorDialogComponent { + protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); + protected selectButtonText = this.i18nService.t( + this.isPassword ? "useThisPassword" : "useThisUsername", + ); + + /** + * Whether the dialog is generating a password/passphrase. If false, it is generating a username. + * @protected + */ + protected get isPassword() { + return this.params.type === "password"; + } + + /** + * The currently generated value. + * @protected + */ + protected generatedValue: string = ""; + + constructor( + @Inject(DIALOG_DATA) protected params: GeneratorDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + ) {} + + /** + * Close the dialog without selecting a value. + */ + protected close = () => { + this.dialogRef.close({ action: GeneratorDialogAction.Canceled }); + }; + + /** + * Close the dialog and select the currently generated value. + */ + protected selectValue = () => { + this.dialogRef.close({ + action: GeneratorDialogAction.Selected, + generatedValue: this.generatedValue, + }); + }; + + onValueGenerated(value: string) { + this.generatedValue = value; + } + + /** + * Opens the vault generator dialog in a full screen dialog. + */ + static open( + dialogService: DialogService, + overlay: Overlay, + config: DialogConfig, + ) { + const position = overlay.position().global(); + + return dialogService.open( + VaultGeneratorDialogComponent, + { + ...config, + positionStrategy: position, + height: "100vh", + width: "100vw", + }, + ); + } +} diff --git a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts new file mode 100644 index 00000000000..b9e8641431f --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts @@ -0,0 +1,45 @@ +import { Overlay } from "@angular/cdk/overlay"; +import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; +import { CipherFormGenerationService } from "@bitwarden/vault"; + +import { VaultGeneratorDialogComponent } from "../components/vault-v2/vault-generator-dialog/vault-generator-dialog.component"; + +@Injectable() +export class BrowserCipherFormGenerationService implements CipherFormGenerationService { + private dialogService = inject(DialogService); + private overlay = inject(Overlay); + + async generatePassword(): Promise { + const dialogRef = VaultGeneratorDialogComponent.open(this.dialogService, this.overlay, { + data: { type: "password" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } + + async generateUsername(): Promise { + const dialogRef = VaultGeneratorDialogComponent.open(this.dialogService, this.overlay, { + data: { type: "username" }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result == null || result.action === "canceled") { + return null; + } + + return result.generatedValue; + } + async generateInitialPassword(): Promise { + return ""; + } +} diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html new file mode 100644 index 00000000000..0a375d5ae51 --- /dev/null +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html @@ -0,0 +1,62 @@ + + + + + {{ "password" | i18n }} + + + {{ "passphrase" | i18n }} + + + + + + + + + + + + + + + + + + + + + + + +

{{ "options" | i18n }}

+
+ + Placeholder: Replace with Generator Options Component(s) when available + +
diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts new file mode 100644 index 00000000000..5b65c6da24d --- /dev/null +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts @@ -0,0 +1,210 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + PasswordGenerationServiceAbstraction, + PasswordGeneratorOptions, + UsernameGenerationServiceAbstraction, + UsernameGeneratorOptions, +} from "@bitwarden/generator-legacy"; +import { CipherFormGeneratorComponent } from "@bitwarden/vault"; + +describe("CipherFormGeneratorComponent", () => { + let component: CipherFormGeneratorComponent; + let fixture: ComponentFixture; + + let mockLegacyPasswordGenerationService: MockProxy; + let mockLegacyUsernameGenerationService: MockProxy; + let mockPlatformUtilsService: MockProxy; + + let passwordOptions$: BehaviorSubject; + let usernameOptions$: BehaviorSubject; + + beforeEach(async () => { + passwordOptions$ = new BehaviorSubject([ + { + type: "password", + }, + ] as [PasswordGeneratorOptions]); + usernameOptions$ = new BehaviorSubject([ + { + type: "word", + }, + ] as [UsernameGeneratorOptions]); + + mockPlatformUtilsService = mock(); + + mockLegacyPasswordGenerationService = mock(); + mockLegacyPasswordGenerationService.getOptions$.mockReturnValue(passwordOptions$); + + mockLegacyUsernameGenerationService = mock(); + mockLegacyUsernameGenerationService.getOptions$.mockReturnValue(usernameOptions$); + + await TestBed.configureTestingModule({ + imports: [CipherFormGeneratorComponent], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: mockLegacyPasswordGenerationService, + }, + { + provide: UsernameGenerationServiceAbstraction, + useValue: mockLegacyUsernameGenerationService, + }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CipherFormGeneratorComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("should use the appropriate text based on generator type", () => { + component.type = "password"; + component.ngOnChanges(); + expect(component["regenerateButtonTitle"]).toBe("regeneratePassword"); + + component.type = "username"; + component.ngOnChanges(); + expect(component["regenerateButtonTitle"]).toBe("regenerateUsername"); + }); + + it("should emit regenerate$ when user clicks the regenerate button", fakeAsync(() => { + const regenerateSpy = jest.spyOn(component["regenerate$"], "next"); + + fixture.nativeElement.querySelector("button[data-testid='regenerate-button']").click(); + + expect(regenerateSpy).toHaveBeenCalled(); + })); + + it("should emit valueGenerated whenever a new value is generated", fakeAsync(() => { + const valueGeneratedSpy = jest.spyOn(component.valueGenerated, "emit"); + + mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password"); + component.type = "password"; + + component.ngOnChanges(); + tick(); + + expect(valueGeneratedSpy).toHaveBeenCalledWith("generated-password"); + })); + + describe("password generation", () => { + beforeEach(() => { + component.type = "password"; + }); + + it("should update the generated value when the password options change", fakeAsync(() => { + mockLegacyPasswordGenerationService.generatePassword + .mockResolvedValueOnce("first-password") + .mockResolvedValueOnce("second-password"); + + component.ngOnChanges(); + tick(); + + expect(component["generatedValue"]).toBe("first-password"); + + passwordOptions$.next([{ type: "password" }]); + tick(); + + expect(component["generatedValue"]).toBe("second-password"); + expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2); + })); + + it("should show password type toggle when the generator type is password", () => { + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy(); + }); + + it("should save password options when the password type is updated", async () => { + mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password"); + + await component["updatePasswordType"]("passphrase"); + + expect(mockLegacyPasswordGenerationService.saveOptions).toHaveBeenCalledWith({ + type: "passphrase", + }); + }); + + it("should update the password history when a new password is generated", fakeAsync(() => { + mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password"); + + component.ngOnChanges(); + tick(); + + expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(1); + expect(mockLegacyPasswordGenerationService.addHistory).toHaveBeenCalledWith("new-password"); + expect(component["generatedValue"]).toBe("new-password"); + })); + + it("should regenerate the password when regenerate$ emits", fakeAsync(() => { + mockLegacyPasswordGenerationService.generatePassword + .mockResolvedValueOnce("first-password") + .mockResolvedValueOnce("second-password"); + + component.ngOnChanges(); + tick(); + + expect(component["generatedValue"]).toBe("first-password"); + + component["regenerate$"].next(); + tick(); + + expect(component["generatedValue"]).toBe("second-password"); + })); + }); + + describe("username generation", () => { + beforeEach(() => { + component.type = "username"; + }); + + it("should update the generated value when the username options change", fakeAsync(() => { + mockLegacyUsernameGenerationService.generateUsername + .mockResolvedValueOnce("first-username") + .mockResolvedValueOnce("second-username"); + + component.ngOnChanges(); + tick(); + + expect(component["generatedValue"]).toBe("first-username"); + + usernameOptions$.next([{ type: "word" }]); + tick(); + + expect(component["generatedValue"]).toBe("second-username"); + })); + + it("should regenerate the username when regenerate$ emits", fakeAsync(() => { + mockLegacyUsernameGenerationService.generateUsername + .mockResolvedValueOnce("first-username") + .mockResolvedValueOnce("second-username"); + + component.ngOnChanges(); + tick(); + + expect(component["generatedValue"]).toBe("first-username"); + + component["regenerate$"].next(); + tick(); + + expect(component["generatedValue"]).toBe("second-username"); + })); + + it("should not show password type toggle when the generator type is username", () => { + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts new file mode 100644 index 00000000000..d7c8e5e93cb --- /dev/null +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -0,0 +1,159 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, EventEmitter, Input, Output } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { firstValueFrom, map, startWith, Subject, Subscription, switchMap, tap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + CardComponent, + ColorPasswordModule, + IconButtonModule, + ItemModule, + SectionComponent, + SectionHeaderComponent, + ToggleGroupModule, + TypographyModule, +} from "@bitwarden/components"; +import { GeneratorType } from "@bitwarden/generator-core"; +import { + PasswordGenerationServiceAbstraction, + UsernameGenerationServiceAbstraction, +} from "@bitwarden/generator-legacy"; + +/** + * Renders a password or username generator UI and emits the most recently generated value. + * Used by the cipher form to be shown in a dialog/modal when generating cipher passwords/usernames. + */ +@Component({ + selector: "vault-cipher-form-generator", + templateUrl: "./cipher-form-generator.component.html", + standalone: true, + imports: [ + CommonModule, + CardComponent, + SectionComponent, + ToggleGroupModule, + JslibModule, + ItemModule, + ColorPasswordModule, + IconButtonModule, + SectionHeaderComponent, + TypographyModule, + ], +}) +export class CipherFormGeneratorComponent { + /** + * The type of generator form to show. + */ + @Input({ required: true }) + type: "password" | "username"; + + /** + * Emits an event when a new value is generated. + */ + @Output() + valueGenerated = new EventEmitter(); + + protected get isPassword() { + return this.type === "password"; + } + + protected regenerateButtonTitle: string; + protected regenerate$ = new Subject(); + /** + * The currently generated value displayed to the user. + * @protected + */ + protected generatedValue: string = ""; + + /** + * The current password generation options. + * @private + */ + private passwordOptions$ = this.legacyPasswordGenerationService.getOptions$(); + + /** + * The current username generation options. + * @private + */ + private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$(); + + /** + * The current password type specified by the password generation options. + * @protected + */ + protected passwordType$ = this.passwordOptions$.pipe(map(([options]) => options.type)); + + /** + * Tracks the regenerate$ subscription + * @private + */ + private subscription: Subscription | null; + + constructor( + private i18nService: I18nService, + private legacyPasswordGenerationService: PasswordGenerationServiceAbstraction, + private legacyUsernameGenerationService: UsernameGenerationServiceAbstraction, + private destroyRef: DestroyRef, + ) {} + + ngOnChanges() { + this.regenerateButtonTitle = this.i18nService.t( + this.isPassword ? "regeneratePassword" : "regenerateUsername", + ); + + // If we have a previous subscription, clear it + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + + if (this.isPassword) { + this.setupPasswordGeneration(); + } else { + this.setupUsernameGeneration(); + } + } + + private setupPasswordGeneration() { + this.subscription = this.regenerate$ + .pipe( + startWith(null), + switchMap(() => this.passwordOptions$), + switchMap(([options]) => this.legacyPasswordGenerationService.generatePassword(options)), + tap(async (password) => { + await this.legacyPasswordGenerationService.addHistory(password); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((password) => { + this.generatedValue = password; + this.valueGenerated.emit(password); + }); + } + + private setupUsernameGeneration() { + this.subscription = this.regenerate$ + .pipe( + startWith(null), + switchMap(() => this.usernameOptions$), + switchMap((options) => this.legacyUsernameGenerationService.generateUsername(options)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((username) => { + this.generatedValue = username; + this.valueGenerated.emit(username); + }); + } + + /** + * Switch the password generation type and save the options (generating a new password automatically). + * @param value The new password generation type. + */ + protected updatePasswordType = async (value: GeneratorType) => { + const [currentOptions] = await firstValueFrom(this.passwordOptions$); + currentOptions.type = value; + await this.legacyPasswordGenerationService.saveOptions(currentOptions); + }; +} diff --git a/libs/vault/src/cipher-form/index.ts b/libs/vault/src/cipher-form/index.ts index 1d275029df1..8cb779a8ec3 100644 --- a/libs/vault/src/cipher-form/index.ts +++ b/libs/vault/src/cipher-form/index.ts @@ -8,3 +8,4 @@ export { export { TotpCaptureService } from "./abstractions/totp-capture.service"; export { CipherFormGenerationService } from "./abstractions/cipher-form-generation.service"; export { DefaultCipherFormConfigService } from "./services/default-cipher-form-config.service"; +export { CipherFormGeneratorComponent } from "./components/cipher-generator/cipher-form-generator.component";