diff --git a/.storybook/main.ts b/.storybook/main.ts index 175ed33948..0dd7094fac 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -6,6 +6,8 @@ const config: StorybookConfig = { stories: [ "../libs/auth/src/**/*.mdx", "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/tools/send/send-ui/src/**/*.mdx", + "../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.mdx", "../libs/vault/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/components/src/**/*.mdx", diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 11475362ba..54543a4212 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -50,7 +50,7 @@ import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/pass import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component"; -import { SendV2Component } from "../tools/popup/send/send-v2.component"; +import { SendV2Component } from "../tools/popup/send-v2/send-v2.component"; import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component"; import { AboutPageComponent } from "../tools/popup/settings/about-page/about-page.component"; import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about-page/more-from-bitwarden-page-v2.component"; diff --git a/apps/browser/src/tools/popup/send/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html similarity index 100% rename from apps/browser/src/tools/popup/send/send-v2.component.html rename to apps/browser/src/tools/popup/send-v2/send-v2.component.html diff --git a/apps/browser/src/tools/popup/send/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts similarity index 100% rename from apps/browser/src/tools/popup/send/send-v2.component.ts rename to apps/browser/src/tools/popup/send-v2/send-v2.component.ts diff --git a/jest.config.js b/jest.config.js index 57f9b3c322..6526237261 100644 --- a/jest.config.js +++ b/jest.config.js @@ -35,6 +35,7 @@ module.exports = { "/libs/tools/generator/extensions/history/jest.config.js", "/libs/tools/generator/extensions/legacy/jest.config.js", "/libs/tools/generator/extensions/navigation/jest.config.js", + "/libs/tools/send/send-ui/jest.config.js", "/libs/importer/jest.config.js", "/libs/platform/jest.config.js", "/libs/node/jest.config.js", diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 97c87e684e..59dbe28f90 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -8,4 +8,5 @@ export type CollectionId = Opaque; export type ProviderId = Opaque; export type PolicyId = Opaque; export type CipherId = Opaque; +export type SendId = Opaque; export type IndexedEntityId = Opaque; diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts index fc7c87449d..7e3b3d7b32 100644 --- a/libs/tools/send/send-ui/src/index.ts +++ b/libs/tools/send/send-ui/src/index.ts @@ -1,2 +1,3 @@ export * from "./icons"; +export * from "./send-form"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts new file mode 100644 index 0000000000..0859986664 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -0,0 +1,93 @@ +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendId } from "@bitwarden/common/types/guid"; + +/** + * The mode of the add/edit form. + * - `add` - The form is creating a new send. + * - `edit` - The form is editing an existing send. + * - `partial-edit` - The form is editing an existing send, but only the favorite/folder fields + */ +export type SendFormMode = "add" | "edit" | "partial-edit"; + +/** + * Base configuration object for the send form. Includes all common fields. + */ +type BaseSendFormConfig = { + /** + * The mode of the form. + */ + mode: SendFormMode; + + /** + * The type of send to create/edit. + */ + sendType: SendType; + + /** + * Flag to indicate if the user is allowed to create sends. If false, configuration must + * supply a list of organizations that the user can create sends in. + */ + areSendsAllowed: boolean; + + /** + * The original send that is being edited or cloned. This can be undefined when creating a new send. + */ + originalSend?: Send; +}; + +/** + * Configuration object for the send form when editing an existing send. + */ +type ExistingSendConfig = BaseSendFormConfig & { + mode: "edit" | "partial-edit"; + originalSend: Send; +}; + +/** + * Configuration object for the send form when creating a completely new send. + */ +type CreateNewSendConfig = BaseSendFormConfig & { + mode: "add"; +}; + +type CombinedAddEditConfig = ExistingSendConfig | CreateNewSendConfig; + +/** + * Configuration object for the send form when personal ownership is allowed. + */ +type SendsAllowed = CombinedAddEditConfig & { + areSendsAllowed: true; +}; + +/** + * Configuration object for the send form when Sends are not allowed by an organization. + * Organizations must be provided. + */ +type SendsNotAllowed = CombinedAddEditConfig & { + areSendsAllowed: false; +}; + +/** + * Configuration object for the send form. + * Determines the behavior of the form and the controls that are displayed/enabled. + */ +export type SendFormConfig = SendsAllowed | SendsNotAllowed; + +/** + * Service responsible for building the configuration object for the send form. + */ +export abstract class SendFormConfigService { + /** + * Builds the configuration for the send form using the specified mode, sendId, and sendType. + * The other configuration fields will be fetched from their respective services. + * @param mode + * @param sendId + * @param sendType + */ + abstract buildConfig( + mode: SendFormMode, + sendId?: SendId, + sendType?: SendType, + ): Promise; +} diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form.service.ts new file mode 100644 index 0000000000..b851db927b --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form.service.ts @@ -0,0 +1,26 @@ +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; + +import { SendFormConfig } from "./send-form-config.service"; + +/** + * Service to save the send using the correct endpoint(s) and encapsulating the logic for decrypting the send. + * + * This service should only be used internally by the SendFormComponent. + */ +export abstract class SendFormService { + /** + * Helper to decrypt a send and avoid the need to call the send service directly. + * (useful for mocking tests/storybook). + */ + abstract decryptSend(send: Send): Promise; + + /** + * Saves the new or modified send with the server. + */ + abstract saveSend( + send: SendView, + file: File | ArrayBuffer, + config: SendFormConfig, + ): Promise; +} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.html b/libs/tools/send/send-ui/src/send-form/components/send-form.component.html new file mode 100644 index 0000000000..2ed7ef4d4f --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts new file mode 100644 index 0000000000..270d610c8a --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -0,0 +1,210 @@ +import { NgIf } from "@angular/common"; +import { + AfterViewInit, + Component, + DestroyRef, + EventEmitter, + forwardRef, + inject, + Input, + OnChanges, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { + AsyncActionsModule, + BitSubmitDirective, + ButtonComponent, + CardComponent, + FormFieldModule, + ItemModule, + SectionComponent, + SelectModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { SendFormConfig } from "../abstractions/send-form-config.service"; +import { SendFormService } from "../abstractions/send-form.service"; +import { SendForm, SendFormContainer } from "../send-form-container"; + +@Component({ + selector: "tools-send-form", + templateUrl: "./send-form.component.html", + standalone: true, + providers: [ + { + provide: SendFormContainer, + useExisting: forwardRef(() => SendFormComponent), + }, + ], + imports: [ + AsyncActionsModule, + CardComponent, + SectionComponent, + TypographyModule, + ItemModule, + FormFieldModule, + ReactiveFormsModule, + SelectModule, + NgIf, + ], +}) +export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, SendFormContainer { + @ViewChild(BitSubmitDirective) + private bitSubmit: BitSubmitDirective; + private destroyRef = inject(DestroyRef); + private _firstInitialized = false; + + /** + * The form ID to use for the form. Used to connect it to a submit button. + */ + @Input({ required: true }) formId: string; + + /** + * The configuration for the add/edit form. Used to determine which controls are shown and what values are available. + */ + @Input({ required: true }) config: SendFormConfig; + + /** + * Optional submit button that will be disabled or marked as loading when the form is submitting. + */ + @Input() + submitBtn?: ButtonComponent; + + /** + * Event emitted when the send is saved successfully. + */ + @Output() sendSaved = new EventEmitter(); + + /** + * The original send being edited or cloned. Null for add mode. + */ + originalSendView: SendView | null; + + /** + * The form group for the send. Starts empty and is populated by child components via the `registerChildForm` method. + * @protected + */ + protected sendForm = this.formBuilder.group({}); + + /** + * The value of the updated send. Starts as a new send and is updated + * by child components via the `patchSend` method. + * @protected + */ + protected updatedSendView: SendView | null; + protected loading: boolean = true; + + SendType = SendType; + + ngAfterViewInit(): void { + if (this.submitBtn) { + this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { + this.submitBtn.loading = loading; + }); + + this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => { + this.submitBtn.disabled = disabled; + }); + } + } + + /** + * Registers a child form group with the parent form group. Used by child components to add their form groups to + * the parent form for validation. + * @param name - The name of the form group. + * @param group - The form group to add. + */ + registerChildForm( + name: K, + group: Exclude, + ): void { + this.sendForm.setControl(name, group); + } + + /** + * Patches the updated send with the provided partial senbd. Used by child components to update the send + * as their form values change. + * @param send + */ + patchSend(send: Partial): void { + this.updatedSendView = Object.assign(this.updatedSendView, send); + } + + /** + * We need to re-initialize the form when the config is updated. + */ + async ngOnChanges() { + // Avoid re-initializing the form on the first change detection cycle. + if (this._firstInitialized) { + await this.init(); + } + } + + async ngOnInit() { + await this.init(); + this._firstInitialized = true; + } + + async init() { + this.loading = true; + this.updatedSendView = new SendView(); + this.originalSendView = null; + this.sendForm.reset(); + + if (this.config == null) { + return; + } + + if (this.config.mode !== "add") { + if (this.config.originalSend == null) { + throw new Error("Original send is required for edit or clone mode"); + } + + this.originalSendView = await this.addEditFormService.decryptSend(this.config.originalSend); + + this.updatedSendView = Object.assign(this.updatedSendView, this.originalSendView); + } else { + this.updatedSendView.type = this.config.sendType; + } + + this.loading = false; + } + + constructor( + private formBuilder: FormBuilder, + private addEditFormService: SendFormService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + submit = async () => { + if (this.sendForm.invalid) { + this.sendForm.markAllAsTouched(); + return; + } + + // TODO: Add file handling + await this.addEditFormService.saveSend(this.updatedSendView, null, this.config); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t( + this.config.mode === "edit" || this.config.mode === "partial-edit" + ? "editedItem" + : "addedItem", + ), + }); + + this.sendSaved.emit(this.updatedSendView); + }; +} diff --git a/libs/tools/send/send-ui/src/send-form/index.ts b/libs/tools/send/send-ui/src/send-form/index.ts new file mode 100644 index 0000000000..06f163dec4 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/index.ts @@ -0,0 +1,7 @@ +export { SendFormModule } from "./send-form.module"; +export { + SendFormConfigService, + SendFormConfig, + SendFormMode, +} from "./abstractions/send-form-config.service"; +export { DefaultSendFormConfigService } from "./services/default-send-form-config.service"; diff --git a/libs/tools/send/send-ui/src/send-form/send-form-container.ts b/libs/tools/send/send-ui/src/send-form/send-form-container.ts new file mode 100644 index 0000000000..01983360e3 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/send-form-container.ts @@ -0,0 +1,36 @@ +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; + +import { SendFormConfig } from "./abstractions/send-form-config.service"; +/** + * The complete form for a send. Includes all the sub-forms from their respective section components. + * TODO: Add additional form sections as they are implemented. + */ +export type SendForm = object; + +/** + * A container for the {@link SendForm} that allows for registration of child form groups and patching of the send + * to be updated/created. Child form components inject this container in order to register themselves with the parent form + * and access configuration options. + * + * This is an alternative to passing the form groups down through the component tree via @Inputs() and form updates via + * @Outputs(). It allows child forms to define their own structure and validation rules, while still being able to + * update the parent send. + */ +export abstract class SendFormContainer { + /** + * The configuration for the send form. + */ + readonly config: SendFormConfig; + + /** + * The original send that is being edited/cloned. Used to pre-populate the form and compare changes. + */ + readonly originalSendView: SendView | null; + + abstract registerChildForm( + name: K, + group: Exclude, + ): void; + + abstract patchSend(send: Partial): void; +} diff --git a/libs/tools/send/send-ui/src/send-form/send-form.mdx b/libs/tools/send/send-ui/src/send-form/send-form.mdx new file mode 100644 index 0000000000..d1297ee90c --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/send-form.mdx @@ -0,0 +1,17 @@ +import { Controls, Meta, Primary } from "@storybook/addon-docs"; + +import * as stories from "./send-form.stories"; + + + +# Send Form + +The send form is a re-usable form component that can be used to create, update, and clone sends. It +is configured via a `SendFormConfig` object that is passed to the component as a prop. The +`SendFormConfig` object can be created manually, or a `SendFormConfigService` can be used to create +it. A default implementation of the `SendFormConfigService` exists in the `@bitwarden/send-ui` +library. + + + + diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts new file mode 100644 index 0000000000..8b004207c1 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from "@angular/core"; + +import { SendFormService } from "./abstractions/send-form.service"; +import { SendFormComponent } from "./components/send-form.component"; +import { DefaultSendFormService } from "./services/default-send-form.service"; + +@NgModule({ + imports: [SendFormComponent], + providers: [ + { + provide: SendFormService, + useClass: DefaultSendFormService, + }, + ], + exports: [SendFormComponent], +}) +export class SendFormModule {} diff --git a/libs/tools/send/send-ui/src/send-form/send-form.stories.ts b/libs/tools/send/send-ui/src/send-form/send-form.stories.ts new file mode 100644 index 0000000000..044b26577e --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/send-form.stories.ts @@ -0,0 +1,132 @@ +import { importProvidersFrom } from "@angular/core"; +import { action } from "@storybook/addon-actions"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; + +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; +import { SendFormConfig } from "@bitwarden/send-ui"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; + +import { SendFormService } from "./abstractions/send-form.service"; +import { SendFormComponent } from "./components/send-form.component"; +import { SendFormModule } from "./send-form.module"; + +const defaultConfig: SendFormConfig = { + mode: "add", + sendType: SendType.Text, + areSendsAllowed: true, + originalSend: { + id: "123", + name: "Test Send", + notes: "Example notes", + } as unknown as Send, +}; + +class TestAddEditFormService implements SendFormService { + decryptSend(): Promise { + return Promise.resolve(defaultConfig.originalSend as any); + } + async saveSend(send: SendView, file: File | ArrayBuffer): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return send; + } +} + +const actionsData = { + onSave: action("onSave"), +}; + +export default { + title: "Tools/Send Form", + component: SendFormComponent, + decorators: [ + moduleMetadata({ + imports: [SendFormModule, AsyncActionsModule, ButtonModule], + providers: [ + { + provide: SendFormService, + useClass: TestAddEditFormService, + }, + { + provide: ToastService, + useValue: { + showToast: action("showToast"), + }, + }, + ], + }), + componentWrapperDecorator( + (story) => `
${story}
`, + ), + applicationConfig({ + providers: [importProvidersFrom(PreloadedEnglishI18nModule)], + }), + ], + args: { + config: defaultConfig, + }, + argTypes: { + config: { + description: "The configuration object for the form.", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + return { + props: { + onSave: actionsData.onSave, + ...args, + }, + template: /*html*/ ` + + + `, + }; + }, +}; + +export const Edit: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "edit", + originalSend: defaultConfig.originalSend, + }, + }, +}; + +export const PartialEdit: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "partial-edit", + originalSend: defaultConfig.originalSend, + }, + }, +}; + +export const SendsHaveBeenDisabledByPolicy: Story = { + ...Default, + args: { + config: { + ...defaultConfig, + mode: "add", + areSendsAllowed: false, + originalSend: defaultConfig.originalSend, + }, + }, +}; diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts new file mode 100644 index 0000000000..5470fe74ce --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -0,0 +1,51 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom, map } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendId } from "@bitwarden/common/types/guid"; + +import { + SendFormConfig, + SendFormConfigService, + SendFormMode, +} from "../abstractions/send-form-config.service"; + +/** + * Default implementation of the `SendFormConfigService`. + */ +@Injectable() +export class DefaultSendFormConfigService implements SendFormConfigService { + private policyService: PolicyService = inject(PolicyService); + private sendService: SendService = inject(SendService); + + async buildConfig( + mode: SendFormMode, + sendId?: SendId, + sendType?: SendType, + ): Promise { + const [areSendsAllowed, send] = await firstValueFrom( + combineLatest([this.areSendsEnabled$, this.getSend(sendId)]), + ); + + return { + mode, + sendType: sendType, + areSendsAllowed, + originalSend: send, + }; + } + + private areSendsEnabled$ = this.policyService + .policyAppliesToActiveUser$(PolicyType.DisableSend) + .pipe(map((p) => !p)); + + private getSend(id?: SendId) { + if (id == null) { + return Promise.resolve(null); + } + return this.sendService.get$(id); + } +} diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts new file mode 100644 index 0000000000..a94fadc708 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts @@ -0,0 +1,29 @@ +import { inject, Injectable } from "@angular/core"; + +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; + +import { SendFormConfig } from "../abstractions/send-form-config.service"; +import { SendFormService } from "../abstractions/send-form.service"; + +@Injectable() +export class DefaultSendFormService implements SendFormService { + private sendApiService: SendApiService = inject(SendApiService); + private sendService = inject(SendService); + + async decryptSend(send: Send): Promise { + return await send.decrypt(); + } + + async saveSend( + send: SendView, + file: File | ArrayBuffer, + config: SendFormConfig, + ): Promise { + const sendData = await this.sendService.encrypt(send, file, send.password, null); + const savedSend = await this.sendApiService.save(sendData); + return await savedSend.decrypt(); + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 50d82bf7d8..637c28a54e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,6 +7,7 @@ config.content = [ "./libs/auth/src/**/*.{html,ts,mdx}", "./libs/billing/src/**/*.{html,ts,mdx}", "./libs/platform/src/**/*.{html,ts,mdx}", + "./libs/tools/send/send-ui/src/*.{html,ts,mdx}", "./libs/vault/src/**/*.{html,ts,mdx}", "./apps/web/src/**/*.{html,ts,mdx}", "./bitwarden_license/bit-web/src/**/*.{html,ts,mdx}", diff --git a/tsconfig.json b/tsconfig.json index 89282287da..79edf0da2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,7 @@ "apps/web/src/**/*", "apps/browser/src/**/*", "libs/*/src/**/*", + "libs/tools/send/**/src/**/*", "bitwarden_license/bit-web/src/**/*", "bitwarden_license/bit-common/src/**/*" ],