diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4e6f27bdd2c..579c87827cc 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2232,6 +2232,27 @@ "message": "All Sends", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "textHiddenByDefault": { + "message": "When accessing the Send, hide the text by default", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached. $ACCESSCOUNT$ views", + "description": "Displayed under the limit views field on Send" + }, + "limitSendViewsHintWithCount": { + "message": "No one can view this Send after the limit is reached. $ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "days": { + "content": "$1", + "example": "2" + } + } + }, "maxAccessCountReached": { "message": "Max access count reached", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." diff --git a/libs/angular/src/utils/extension-refresh-swap.ts b/libs/angular/src/utils/extension-refresh-swap.ts index 6512be032d2..1f5433feab8 100644 --- a/libs/angular/src/utils/extension-refresh-swap.ts +++ b/libs/angular/src/utils/extension-refresh-swap.ts @@ -23,6 +23,7 @@ export function extensionRefreshSwap( defaultComponent, refreshedComponent, async () => { + return true; const configService = inject(ConfigService); return configService.getFeatureFlag(FeatureFlag.ExtensionRefresh); }, diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 100985c4870..4109df19680 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -36,5 +36,5 @@ export abstract class SendApiService { renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise; removePassword: (id: string) => Promise; delete: (id: string) => Promise; - save: (sendData: [Send, EncArrayBuffer]) => Promise; + save: (sendData: [Send, EncArrayBuffer]) => Promise; } diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 2cb2ff1c2f0..9ab6fb8827f 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendId } from "@bitwarden/common/types/guid"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -135,11 +137,13 @@ export class SendApiService implements SendApiServiceAbstraction { return this.apiService.send("DELETE", "/sends/" + id, null, true, false); } - async save(sendData: [Send, EncArrayBuffer]): Promise { + async save(sendData: [Send, EncArrayBuffer]): Promise { const response = await this.upload(sendData); const data = new SendData(response); - await this.sendService.upsert(data); + const updated = await this.sendService.upsert(data); + // No local data for new Sends + return new Send(updated[response.id as SendId]); } async delete(id: string): Promise { diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 4fa927942c1..bee773be93d 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -4,7 +4,7 @@ import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { UserId } from "../../../types/guid"; +import { SendId, UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; @@ -54,7 +54,7 @@ export abstract class SendService implements UserKeyRotationDataProvider Promise; + upsert: (send: SendData | SendData[]) => Promise>; replace: (sends: { [id: string]: SendData }, userId: UserId) => Promise; - delete: (id: string | string[]) => Promise; + delete: (id: string | string[]) => Promise; } diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 63c07e862ff..25937e7da1f 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -57,6 +57,8 @@ export class SendService implements InternalSendServiceAbstraction { send.disabled = model.disabled; send.hideEmail = model.hideEmail; send.maxAccessCount = model.maxAccessCount; + send.deletionDate = model.deletionDate; + send.expirationDate = model.expirationDate; if (model.key == null) { const key = await this.keyGenerationService.createKeyWithPurpose( 128, diff --git a/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.html b/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.html new file mode 100644 index 00000000000..87e9adcc471 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.html @@ -0,0 +1,39 @@ + + +

{{ "additionalOptions" | i18n }}

+
+ + + + {{ "limitSendViews" | i18n }} + + {{ "limitSendViewsHint" | i18n }} + {{ "limitSendViewsHintWithCount" | i18n }} + + + + {{ "currentAccessCount" | i18n }} + + + + + {{ "password" | i18n }} + {{ "newPassword" | i18n }} + + + + + {{ "sendPasswordDesc" | i18n }} + + + + + {{ "hideEmail" | i18n }} + + + + {{ "notes" | i18n }} + + + +
diff --git a/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.ts new file mode 100644 index 00000000000..6d0865fb834 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/options/options-section.component.ts @@ -0,0 +1,91 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { + CardComponent, + CheckboxModule, + FormFieldModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, +} from "@bitwarden/components"; + +import { SendFormConfig } from "../../abstractions/send-form-config.service"; +import { SendFormContainer } from "../../send-form-container"; + +@Component({ + selector: "tools-send-options-section", + templateUrl: "./options-section.component.html", + standalone: true, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + JslibModule, + CardComponent, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + CommonModule, + ], +}) +export class AdditionalOptionsSectionComponent implements OnInit { + @Input({ required: true }) + config: SendFormConfig; + + @Input() + originalSendView: SendView; + + additionalOptionsForm = this.formBuilder.group({ + maxAccessCount: [null as number], + accessCount: [null as number], + notes: [null as string], + password: [null as string], + hideEmail: [false as boolean], + }); + + get hasPassword(): boolean { + return ( + this.additionalOptionsForm.value.password !== null && + this.additionalOptionsForm.value.password !== "" + ); + } + + constructor( + private sendFormContainer: SendFormContainer, + private formBuilder: FormBuilder, + ) { + this.sendFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm); + + this.additionalOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + maxAccessCount: value.maxAccessCount, + accessCount: value.accessCount, + password: value.password, + hideEmail: value.hideEmail, + notes: value.notes, + }); + return send; + }); + }); + } + + ngOnInit() { + if (this.sendFormContainer.originalSendView) { + this.additionalOptionsForm.patchValue({ + maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, + accessCount: this.sendFormContainer.originalSendView.accessCount, + password: this.sendFormContainer.originalSendView.password, + hideEmail: this.sendFormContainer.originalSendView.hideEmail, + notes: this.sendFormContainer.originalSendView.notes, + }); + } + } +} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.html new file mode 100644 index 00000000000..6475aa5f666 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.html @@ -0,0 +1,55 @@ + + + +

Send details

+
+ + + + {{ "name" | i18n }} + + + + + {{ "sendTypeText" | i18n }} + + {{ "sendTextDesc" | i18n }} + + + + {{ "textHiddenByDefault" | i18n }} + + + + {{ "sendLinkLabel" | i18n }} + + + + + + {{ "deletionDate" | i18n }} + + + + + + + {{ "deletionDateDesc" | i18n }} + + +
diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts new file mode 100644 index 00000000000..3522afefedb --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts @@ -0,0 +1,151 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { + CardComponent, + CheckboxModule, + FormFieldModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { SendFormConfig } from "../../abstractions/send-form-config.service"; +import { SendFormContainer } from "../../send-form-container"; + +// Value = hours +enum DatePreset { + OneHour = 1, + OneDay = 24, + TwoDays = 48, + ThreeDays = 72, + SevenDays = 168, + ThirtyDays = 720, + Custom = 0, + Never = null, +} + +interface DatePresetSelectOption { + name: string; + value: DatePreset; +} + +@Component({ + selector: "tools-send-text-details", + templateUrl: "./send-text-details.component.html", + standalone: true, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + JslibModule, + CardComponent, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + CommonModule, + SelectModule, + ], +}) +export class SendTextDetailsComponent implements OnInit { + @Input({ required: true }) + config: SendFormConfig; + + @Input() + originalSendView: SendView; + + sendTextDetailsForm = this.formBuilder.group({ + name: ["", [Validators.required]], + textToShare: [""], + hideTextByDefault: [false], + // sendLink: [null as string], + defaultDeletionDateTime: ["", Validators.required], + selectedDeletionDatePreset: [DatePreset.SevenDays, Validators.required], + }); + + deletionDatePresets: DatePresetSelectOption[] = [ + { name: this.i18nService.t("oneHour"), value: DatePreset.OneHour }, + { name: this.i18nService.t("oneDay"), value: DatePreset.OneDay }, + { name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays }, + { name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays }, + { name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays }, + { name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays }, + { name: this.i18nService.t("custom"), value: DatePreset.Custom }, + ]; + + constructor( + private sendFormContainer: SendFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + protected datePipe: DatePipe, + ) { + this.sendFormContainer.registerChildForm("sendTextDetailsForm", this.sendTextDetailsForm); + + this.sendTextDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + name: value.name, + text: { text: value.textToShare, hidden: value.hideTextByDefault }, + deletionDate: new Date(this.formattedDeletionDate), + expirationDate: new Date(this.formattedDeletionDate), + } as SendView); + return send; + }); + }); + } + + async ngOnInit() { + if (this.originalSendView) { + this.sendTextDetailsForm.patchValue({ + name: this.originalSendView.name, + textToShare: this.originalSendView.text.text, + hideTextByDefault: this.originalSendView.text.hidden, + defaultDeletionDateTime: this.datePipe.transform( + new Date(this.originalSendView.deletionDate), + "yyyy-MM-ddTHH:mm", + ), + selectedDeletionDatePreset: + this.config.mode === "edit" ? DatePreset.Custom : DatePreset.SevenDays, + }); + } + + this.sendTextDetailsForm.controls.selectedDeletionDatePreset.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((datePreset) => { + datePreset === DatePreset.Custom + ? this.sendTextDetailsForm.controls.defaultDeletionDateTime.enable() + : this.sendTextDetailsForm.controls.defaultDeletionDateTime.disable(); + }); + } + + get formattedDeletionDate(): string { + switch (this.sendTextDetailsForm.controls.selectedDeletionDatePreset.value as DatePreset) { + case DatePreset.Never: + this.sendTextDetailsForm.controls.selectedDeletionDatePreset.patchValue( + DatePreset.SevenDays, + ); + return this.formattedDeletionDate; + case DatePreset.Custom: + return this.sendTextDetailsForm.controls.defaultDeletionDateTime.value; + default: { + const now = new Date(); + const milliseconds = now.setTime( + now.getTime() + + (this.sendTextDetailsForm.controls.selectedDeletionDatePreset.value as number) * + 60 * + 60 * + 1000, + ); + return new Date(milliseconds).toString(); + } + } + } +} 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 index 2ed7ef4d4f1..198d1983cc3 100644 --- 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 @@ -1,4 +1,14 @@
- + + + +
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 index 270d610c8a1..54c7284784e 100644 --- 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 @@ -35,6 +35,9 @@ import { SendFormConfig } from "../abstractions/send-form-config.service"; import { SendFormService } from "../abstractions/send-form.service"; import { SendForm, SendFormContainer } from "../send-form-container"; +import { AdditionalOptionsSectionComponent } from "./options/options-section.component"; +import { SendTextDetailsComponent } from "./send-details/send-text-details.component"; + @Component({ selector: "tools-send-form", templateUrl: "./send-form.component.html", @@ -55,6 +58,8 @@ import { SendForm, SendFormContainer } from "../send-form-container"; ReactiveFormsModule, SelectModule, NgIf, + AdditionalOptionsSectionComponent, + SendTextDetailsComponent, ], }) export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, SendFormContainer { @@ -131,12 +136,11 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send } /** - * Patches the updated send with the provided partial senbd. Used by child components to update the send - * as their form values change. - * @param send + * Method to update the sendView with the new values. This method should be called by the child form components + * @param updateFn - A function that takes the current sendView and returns the updated sendView */ - patchSend(send: Partial): void { - this.updatedSendView = Object.assign(this.updatedSendView, send); + patchSend(updateFn: (current: SendView) => SendView): void { + this.updatedSendView = updateFn(this.updatedSendView); } /** 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 index 01983360e3e..d54d79db171 100644 --- 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 @@ -1,11 +1,16 @@ import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendFormConfig } from "./abstractions/send-form-config.service"; +import { AdditionalOptionsSectionComponent } from "./components/options/options-section.component"; +import { SendTextDetailsComponent } from "./components/send-details/send-text-details.component"; /** * 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; +export type SendForm = { + sendTextDetailsForm?: SendTextDetailsComponent["sendTextDetailsForm"]; + additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; +}; /** * A container for the {@link SendForm} that allows for registration of child form groups and patching of the send @@ -32,5 +37,5 @@ export abstract class SendFormContainer { group: Exclude, ): void; - abstract patchSend(send: Partial): void; + abstract patchSend(updateFn: (current: SendView) => SendView): void; }