diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c65535e2e5..f24d1db484 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2298,6 +2298,24 @@ "message": "Send created", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "createdSendSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendAvailability": { + "message": "The Send will be available to anyone with the link for the next $DAYS$ days.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "days": { + "content": "$1", + "example": "5" + } + } + }, + "sendLinkCopied": { + "message": "Send link copied", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3813,6 +3831,9 @@ "copySuccessful": { "message": "Copy Successful" }, + "copyLink": { + "message": "Copy link" + }, "upload": { "message": "Upload" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 5c4c01bfc1..af85057550 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -54,6 +54,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 { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.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"; @@ -370,6 +371,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "edit-send" }, }, + { + path: "send-created", + component: SendCreatedComponent, + canActivate: [authGuard], + data: { state: "send" }, + }, { path: "update-temp-password", component: UpdateTempPasswordComponent, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html new file mode 100644 index 0000000000..9b56fa74d9 --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -0,0 +1,28 @@ +
+ + + + + + + +
+ +

{{ "createdSendSuccessfully" | i18n }}

+

{{ "sendAvailability" | i18n: daysAvailable }}

+ +
+ + + + +
+
diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts new file mode 100644 index 0000000000..413f22565e --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -0,0 +1,140 @@ +import { CommonModule, Location } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; + +import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +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"; +import { PopupRouterCacheService } from "../../../../platform/popup/view-cache/popup-router-cache.service"; + +import { SendCreatedComponent } from "./send-created.component"; + +describe("SendCreatedComponent", () => { + let component: SendCreatedComponent; + let fixture: ComponentFixture; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + let sendService: MockProxy; + let toastService: MockProxy; + let location: MockProxy; + let activatedRoute: MockProxy; + let environmentService: MockProxy; + + const sendId = "test-send-id"; + const deletionDate = new Date(); + deletionDate.setDate(deletionDate.getDate() + 7); + const sendView: SendView = { + id: sendId, + deletionDate, + accessId: "abc", + urlB64Key: "123", + } as SendView; + + beforeEach(async () => { + i18nService = mock(); + platformUtilsService = mock(); + sendService = mock(); + toastService = mock(); + location = mock(); + activatedRoute = mock(); + environmentService = mock(); + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); + + activatedRoute.snapshot = { + queryParamMap: { + get: jest.fn().mockReturnValue(sendId), + }, + } as any; + + sendService.sendViews$ = of([sendView]); + + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + RouterTestingModule, + JslibModule, + ButtonModule, + IconModule, + PopOutComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterLink, + PopupFooterComponent, + SendCreatedComponent, + ], + providers: [ + { provide: I18nService, useValue: i18nService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: SendService, useValue: sendService }, + { provide: ToastService, useValue: toastService }, + { provide: Location, useValue: location }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: ConfigService, useValue: mock() }, + { provide: EnvironmentService, useValue: environmentService }, + { provide: PopupRouterCacheService, useValue: mock() }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SendCreatedComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("should initialize send and daysAvailable", () => { + fixture.detectChanges(); + expect(component["send"]).toBe(sendView); + expect(component["daysAvailable"]).toBe(7); + }); + + it("should navigate back on close", () => { + fixture.detectChanges(); + component.close(); + expect(location.back).toHaveBeenCalled(); + }); + + describe("getDaysAvailable", () => { + it("returns the correct number of days", () => { + fixture.detectChanges(); + expect(component.getDaysAvailable(sendView)).toBe(7); + }); + }); + + describe("copyLink", () => { + it("should copy link and show toast", async () => { + fixture.detectChanges(); + const link = "https://example.com/#/send/abc/123"; + + await component.copyLink(); + + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith(link); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: i18nService.t("sendLinkCopied"), + }); + }); + }); +}); diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts new file mode 100644 index 0000000000..92339774d0 --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -0,0 +1,82 @@ +import { CommonModule, Location } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { SendCreatedIcon } from "@bitwarden/send-ui"; + +import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; +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"; + +@Component({ + selector: "app-send-created", + templateUrl: "./send-created.component.html", + standalone: true, + imports: [ + ButtonModule, + CommonModule, + JslibModule, + PopOutComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterLink, + PopupFooterComponent, + IconModule, + ], +}) +export class SendCreatedComponent { + protected sendCreatedIcon = SendCreatedIcon; + protected send: SendView; + protected daysAvailable = 0; + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private sendService: SendService, + private route: ActivatedRoute, + private toastService: ToastService, + private location: Location, + private environmentService: EnvironmentService, + ) { + const sendId = this.route.snapshot.queryParamMap.get("sendId"); + this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => { + this.send = sendViews.find((s) => s.id === sendId); + if (this.send) { + this.daysAvailable = this.getDaysAvailable(this.send); + } + }); + } + + getDaysAvailable(send: SendView): number { + const now = new Date().getTime(); + return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60 * 24))); + } + + close() { + this.location.back(); + } + + async copyLink() { + if (!this.send || !this.send.accessId || !this.send.urlB64Key) { + return; + } + const env = await firstValueFrom(this.environmentService.environment$); + const link = env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key; + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("sendLinkCopied"), + }); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 265647f063..41af62c55c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4836,6 +4836,9 @@ "copySendLinkOnSave": { "message": "Copy the link to share this Send to my clipboard upon save." }, + "copyLink": { + "message": "Copy link" + }, "sendLinkLabel": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 9ed5bed014..5d04127192 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,5 +1,8 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; + +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; import { FakeAccountService, @@ -41,6 +44,7 @@ describe("SendService", () => { const i18nService = mock(); const keyGenerationService = mock(); const encryptService = mock(); + const environmentService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -56,6 +60,10 @@ describe("SendService", () => { accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); sendStateProvider = new SendStateProvider(stateProvider); + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); diff --git a/libs/tools/send/send-ui/src/icons/index.ts b/libs/tools/send/send-ui/src/icons/index.ts index a2428e5633..4460070f43 100644 --- a/libs/tools/send/send-ui/src/icons/index.ts +++ b/libs/tools/send/send-ui/src/icons/index.ts @@ -1,2 +1,3 @@ -export { NoSendsIcon } from "./no-send.icon"; export { ExpiredSendIcon } from "./expired-send.icon"; +export { NoSendsIcon } from "./no-send.icon"; +export { SendCreatedIcon } from "./send-created.icon"; diff --git a/libs/tools/send/send-ui/src/icons/send-created.icon.ts b/libs/tools/send/send-ui/src/icons/send-created.icon.ts new file mode 100644 index 0000000000..bb4bc2dd3b --- /dev/null +++ b/libs/tools/send/send-ui/src/icons/send-created.icon.ts @@ -0,0 +1,16 @@ +import { svgIcon } from "@bitwarden/components"; + +export const SendCreatedIcon = svgIcon` + + + + + + + + + + + + +`; diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts index 847283ef5e..d3651fc12c 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts @@ -12,6 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; 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 { ButtonModule, BadgeModule, @@ -30,6 +31,7 @@ describe("SendListItemsContainerComponent", () => { let component: SendListItemsContainerComponent; let fixture: ComponentFixture; let environmentService: MockProxy; + let sendService: MockProxy; const openSimpleDialog = jest.fn(); const showToast = jest.fn(); @@ -37,6 +39,8 @@ describe("SendListItemsContainerComponent", () => { const deleteFn = jest.fn().mockResolvedValue(undefined); beforeEach(async () => { + sendService = mock(); + await TestBed.configureTestingModule({ imports: [ CommonModule, @@ -57,6 +61,7 @@ describe("SendListItemsContainerComponent", () => { { provide: PlatformUtilsService, useValue: { copyToClipboard } }, { provide: SendApiService, useValue: { delete: deleteFn } }, { provide: ToastService, useValue: { showToast } }, + { provide: SendService, useValue: sendService }, ], }) .overrideProvider(DialogService, { @@ -113,10 +118,11 @@ describe("SendListItemsContainerComponent", () => { it("should copy send link", async () => { const send = { id: "123", accessId: "abc", urlB64Key: "xyz" } as SendView; + const link = "https://example.com/#/send/abc/xyz"; await component.copySendLink(send); - expect(copyToClipboard).toHaveBeenCalledWith("https://example.com/#/send/abc/xyz"); + expect(copyToClipboard).toHaveBeenCalledWith(link); expect(showToast).toHaveBeenCalledWith({ variant: "success", title: null, diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index 9551fe07ee..2dd8078fd7 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -85,9 +85,9 @@ export class SendListItemsContainerComponent { } } - async copySendLink(s: SendView) { + async copySendLink(send: SendView) { const env = await firstValueFrom(this.environmentService.environment$); - const link = env.getSendUrl() + s.accessId + "/" + s.urlB64Key; + const link = env.getSendUrl() + send.accessId + "/" + send.urlB64Key; this.platformUtilsService.copyToClipboard(link); this.toastService.showToast({ variant: "success",