From 5ce4e8f4e53a7e53e71b17e89e8c2a6b698891ef Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 27 Jun 2024 10:50:45 -0500 Subject: [PATCH] [PM-7897] Attachments - Part 1 (#9715) * add v2 attachments page * add add attachment fields * add file upload UI * move cipher-attachments to a new component * load cipher and add initial submit * add list of existing attachments * fix incorrect toast usage * integrate with bit submit states * add new max file translation without the period * refactor attachments v2 component * remove default list styles * add tests for attachments components * use `CipherId` type * pass submit button reference to the underlying form * remove bitFormButton * [PM-7897] Attachments Part 2 (#9755) * make `isNew` param optional * emit success output after upload * navigate the user to the edit screen after an upload * allow for the deletion of an attachment * add download attachment component to attachments view * implement base attachment link * add premium redirect * show specific error message for free organizations * make open-attachments a button so it is keyboard accessible * fix lint error * use bitItem * using bitAction rather than standalone loading/deleting value * remove extra title, unneeded because of the appA11yTitle usage * use `replaceUrl` to avoid the back button going to the attachments page * use bit-item for consistency * show error when a user tries to open an attachment that is a part of a free org * add `CipherId` type for failed builds --- apps/browser/src/_locales/en/messages.json | 36 +++ apps/browser/src/popup/app-routing.module.ts | 6 +- .../add-edit/add-edit-v2.component.html | 2 + .../add-edit/add-edit-v2.component.ts | 9 +- .../attachments/attachments-v2.component.html | 24 ++ .../attachments-v2.component.spec.ts | 122 +++++++++ .../attachments/attachments-v2.component.ts | 62 +++++ .../cipher-attachments.component.html | 66 +++++ .../cipher-attachments.component.spec.ts | 255 ++++++++++++++++++ .../cipher-attachments.component.ts | 211 +++++++++++++++ .../delete-attachment.component.html | 9 + .../delete-attachment.component.spec.ts | 105 ++++++++ .../delete-attachment.component.ts | 66 +++++ .../download-attachment.component.html | 8 + .../download-attachment.component.spec.ts | 144 ++++++++++ .../download-attachment.component.ts | 104 +++++++ .../open-attachments.component.html | 14 + .../open-attachments.component.spec.ts | 166 ++++++++++++ .../open-attachments.component.ts | 101 +++++++ 19 files changed, 1506 insertions(+), 4 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 64f039bb8b2..2675e38ee8c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3498,6 +3498,42 @@ "contactYourOrgAdmin": { "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." }, + "upload": { + "message": "Upload" + }, + "addAttachment":{ + "message": "Add attachment" + }, + "maxFileSizeSansPunctuation": { + "message": "Maximum file size is 500 MB" + }, + "deleteAttachmentName": { + "message": "Delete attachment $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "Attachment Name" + } + } + }, + "downloadAttachmentName": { + "message": "Download $NAME$", + "placeholders": { + "name": { + "content": "$1", + "example": "Attachment Name" + } + } + }, + "permanentlyDeleteAttachmentConfirmation": { + "message": "Are you sure you want to permanently delete this attachment?" + }, + "premium": { + "message": "Premium" + }, + "freeOrgsCannotUseAttachments": { + "message": "Free organizations cannot use attachments" + }, "filters": { "message": "Filters" } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 51152ba0f71..69e6d36afa9 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -68,6 +68,7 @@ import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; +import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { FoldersComponent } from "../vault/popup/settings/folders.component"; @@ -230,12 +231,11 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "collections" }, }, - { + ...extensionRefreshSwap(AttachmentsComponent, AttachmentsV2Component, { path: "attachments", - component: AttachmentsComponent, canActivate: [AuthGuard], data: { state: "attachments" }, - }, + }), { path: "generator", component: GeneratorComponent, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 09b764cbc8f..c6fd12b2493 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -1,6 +1,8 @@ + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts new file mode 100644 index 00000000000..0f09d12db9f --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -0,0 +1,122 @@ +import { Component, Input } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { ActivatedRoute, Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { ButtonComponent } from "@bitwarden/components"; + +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; + +import { AttachmentsV2Component } from "./attachments-v2.component"; +import { CipherAttachmentsComponent } from "./cipher-attachments/cipher-attachments.component"; + +@Component({ + standalone: true, + selector: "popup-header", + template: ``, +}) +class MockPopupHeaderComponent { + @Input() pageTitle: string; +} + +@Component({ + standalone: true, + selector: "popup-footer", + template: ``, +}) +class MockPopupFooterComponent { + @Input() pageTitle: string; +} + +describe("AttachmentsV2Component", () => { + let component: AttachmentsV2Component; + let fixture: ComponentFixture; + const queryParams = new BehaviorSubject<{ cipherId: string }>({ cipherId: "5555-444-3333" }); + let cipherAttachment: CipherAttachmentsComponent; + const navigate = jest.fn(); + + const cipherDomain = { + type: CipherType.Login, + name: "Test Login", + }; + + const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain); + + beforeEach(async () => { + cipherServiceGet.mockClear(); + navigate.mockClear(); + + await TestBed.configureTestingModule({ + imports: [AttachmentsV2Component], + providers: [ + { provide: LogService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: Router, useValue: { navigate } }, + { + provide: ActivatedRoute, + useValue: { + queryParams, + }, + }, + { + provide: CipherService, + useValue: { + get: cipherServiceGet, + }, + }, + ], + }) + .overrideComponent(AttachmentsV2Component, { + remove: { + imports: [PopupHeaderComponent, PopupFooterComponent], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupFooterComponent], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AttachmentsV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + cipherAttachment = fixture.debugElement.query( + By.directive(CipherAttachmentsComponent), + ).componentInstance; + }); + + it("sets `cipherId` from query params", () => { + expect(component.cipherId).toBe("5555-444-3333"); + }); + + it("passes the submit button to the cipher attachments component", () => { + const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] + .componentInstance; + + expect(cipherAttachment.submitBtn).toEqual(submitBtn); + }); + + it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => { + cipherAttachment.onUploadSuccess.emit(); + + tick(); + + expect(navigate).toHaveBeenCalledWith(["/edit-cipher"], { + queryParams: { cipherId: "5555-444-3333", type: CipherType.Login }, + replaceUrl: true, + }); + })); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts new file mode 100644 index 00000000000..da0def529c2 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts @@ -0,0 +1,62 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { first } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { ButtonModule } 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 { CipherAttachmentsComponent } from "./cipher-attachments/cipher-attachments.component"; + +@Component({ + standalone: true, + selector: "app-attachments-v2", + templateUrl: "./attachments-v2.component.html", + imports: [ + CommonModule, + ButtonModule, + JslibModule, + CipherAttachmentsComponent, + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + PopOutComponent, + ], +}) +export class AttachmentsV2Component { + /** The `id` tied to the underlying HTMLFormElement */ + attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + + /** Id of the cipher */ + cipherId: CipherId; + + constructor( + private router: Router, + private cipherService: CipherService, + route: ActivatedRoute, + ) { + route.queryParams.pipe(takeUntilDestroyed(), first()).subscribe(({ cipherId }) => { + this.cipherId = cipherId; + }); + } + + /** Navigate the user back to the edit screen after uploading an attachment */ + async navigateToEditScreen() { + const cipherDomain = await this.cipherService.get(this.cipherId); + + void this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: this.cipherId, type: cipherDomain.type }, + // "replaceUrl" so the /attachments route is not in the history, thus when a back button + // is clicked, the user is taken to the view screen instead of the attachments screen + replaceUrl: true, + }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.html b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.html new file mode 100644 index 00000000000..bcadf7a4336 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.html @@ -0,0 +1,66 @@ +

{{ "attachments" | i18n }}

+ +
    +
  • + + + {{ attachment.fileName }} + {{ attachment.sizeName }} + + + + + + + + + + +
  • +
+ +
+ + +
+ + + + + +
+

+ {{ "maxFileSizeSansPunctuation" | i18n }} +

+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.spec.ts new file mode 100644 index 00000000000..8b5a76b3f32 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.spec.ts @@ -0,0 +1,255 @@ +import { Component, Input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; + +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ButtonComponent, ToastService } from "@bitwarden/components"; + +import { CipherAttachmentsComponent } from "./cipher-attachments.component"; +import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component"; +import { DownloadAttachmentComponent } from "./download-attachment/download-attachment.component"; + +@Component({ + standalone: true, + selector: "app-download-attachment", + template: "", +}) +class MockDownloadAttachmentComponent { + @Input() attachment: AttachmentView; + @Input() cipher: CipherView; +} + +describe("CipherAttachmentsComponent", () => { + let component: CipherAttachmentsComponent; + let fixture: ComponentFixture; + const showToast = jest.fn(); + const cipherView = { + id: "5555-444-3333", + type: CipherType.Login, + name: "Test Login", + login: { + username: "username", + password: "password", + }, + } as CipherView; + + const cipherDomain = { + decrypt: () => cipherView, + }; + + const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain); + const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain); + + beforeEach(async () => { + cipherServiceGet.mockClear(); + showToast.mockClear(); + saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain); + + await TestBed.configureTestingModule({ + imports: [CipherAttachmentsComponent], + providers: [ + { + provide: CipherService, + useValue: { + get: cipherServiceGet, + saveAttachmentWithServer, + getKeyForCipherKeyDecryption: () => Promise.resolve(null), + }, + }, + { + provide: ToastService, + useValue: { + showToast, + }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: LogService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + ], + }) + .overrideComponent(CipherAttachmentsComponent, { + remove: { + imports: [DownloadAttachmentComponent], + }, + add: { + imports: [MockDownloadAttachmentComponent], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CipherAttachmentsComponent); + component = fixture.componentInstance; + component.cipherId = "5555-444-3333" as CipherId; + component.submitBtn = {} as ButtonComponent; + fixture.detectChanges(); + }); + + it("fetches cipherView using `cipherId`", async () => { + await component.ngOnInit(); + + expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333"); + expect(component.cipher).toEqual(cipherView); + }); + + describe("bitSubmit", () => { + beforeEach(() => { + component.submitBtn.disabled = undefined; + component.submitBtn.loading = undefined; + }); + + it("updates sets initial state of the submit button", async () => { + await component.ngOnInit(); + + expect(component.submitBtn.disabled).toBe(true); + }); + + it("sets submitBtn loading state", () => { + component.bitSubmit.loading = true; + + expect(component.submitBtn.loading).toBe(true); + + component.bitSubmit.loading = false; + + expect(component.submitBtn.loading).toBe(false); + }); + + it("sets submitBtn disabled state", () => { + component.bitSubmit.disabled = true; + + expect(component.submitBtn.disabled).toBe(true); + + component.bitSubmit.disabled = false; + + expect(component.submitBtn.disabled).toBe(false); + }); + }); + + describe("attachmentForm", () => { + let file: File; + + beforeEach(() => { + component.submitBtn.disabled = undefined; + file = new File([""], "attachment.txt", { type: "text/plain" }); + + const inputElement = fixture.debugElement.query(By.css("input[type=file]")); + + // Set the file value of the input element + Object.defineProperty(inputElement.nativeElement, "files", { + value: [file], + writable: false, + }); + + // Trigger change event, for event listeners + inputElement.nativeElement.dispatchEvent(new InputEvent("change")); + }); + + it("sets value of `file` control when input changes", () => { + expect(component.attachmentForm.controls.file.value.name).toEqual(file.name); + }); + + it("updates disabled state of submit button", () => { + expect(component.submitBtn.disabled).toBe(false); + }); + }); + + describe("submit", () => { + describe("error", () => { + it("shows error toast if no file is selected", async () => { + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "selectFile", + }); + }); + + it("shows error toast if file size is greater than 500MB", async () => { + component.attachmentForm.controls.file.setValue({ + size: 524288001, + } as File); + + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "maxFileSize", + }); + }); + }); + + describe("success", () => { + const file = { size: 524287999 } as File; + + beforeEach(() => { + component.attachmentForm.controls.file.setValue(file); + }); + + it("calls `saveAttachmentWithServer`", async () => { + await component.submit(); + + expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file); + }); + + it("resets form and input values", async () => { + await component.submit(); + + const fileInput = fixture.debugElement.query(By.css("input[type=file]")); + + expect(fileInput.nativeElement.value).toEqual(""); + expect(component.attachmentForm.controls.file.value).toEqual(null); + }); + + it("shows success toast", async () => { + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "attachmentSaved", + }); + }); + + it('emits "onUploadSuccess"', async () => { + const emitSpy = jest.spyOn(component.onUploadSuccess, "emit"); + + await component.submit(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("removeAttachment", () => { + const attachment = { id: "1234-5678" } as AttachmentView; + + beforeEach(() => { + component.cipher.attachments = [attachment]; + + fixture.detectChanges(); + }); + + it("removes attachment from cipher", () => { + const deleteAttachmentComponent = fixture.debugElement.query( + By.directive(DeleteAttachmentComponent), + ).componentInstance as DeleteAttachmentComponent; + + deleteAttachmentComponent.onDeletionSuccess.emit(); + + expect(component.cipher.attachments).toEqual([]); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.ts new file mode 100644 index 00000000000..21115955653 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/cipher-attachments.component.ts @@ -0,0 +1,211 @@ +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, + inject, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + AsyncActionsModule, + BitSubmitDirective, + ButtonComponent, + ButtonModule, + CardComponent, + ItemModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component"; +import { DownloadAttachmentComponent } from "./download-attachment/download-attachment.component"; + +type CipherAttachmentForm = FormGroup<{ + file: FormControl; +}>; + +@Component({ + standalone: true, + selector: "app-cipher-attachments", + templateUrl: "./cipher-attachments.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CommonModule, + ItemModule, + JslibModule, + ReactiveFormsModule, + TypographyModule, + CardComponent, + DeleteAttachmentComponent, + DownloadAttachmentComponent, + ], +}) +export class CipherAttachmentsComponent implements OnInit, AfterViewInit { + /** `id` associated with the form element */ + static attachmentFormID = "attachmentForm"; + + /** Reference to the file HTMLInputElement */ + @ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef; + + /** Reference to the BitSubmitDirective */ + @ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective; + + /** The `id` of the cipher in context */ + @Input({ required: true }) cipherId: CipherId; + + /** An optional submit button, whose loading/disabled state will be tied to the form state. */ + @Input() submitBtn?: ButtonComponent; + + /** Emits after a file has been successfully uploaded */ + @Output() onUploadSuccess = new EventEmitter(); + + cipher: CipherView; + + attachmentForm: CipherAttachmentForm = this.formBuilder.group({ + file: new FormControl(null, [Validators.required]), + }); + + private cipherDomain: Cipher; + private destroy$ = inject(DestroyRef); + + constructor( + private cipherService: CipherService, + private i18nService: I18nService, + private formBuilder: FormBuilder, + private logService: LogService, + private toastService: ToastService, + ) { + this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => { + if (!this.submitBtn) { + return; + } + + this.submitBtn.disabled = status !== "VALID"; + }); + } + + async ngOnInit(): Promise { + this.cipherDomain = await this.cipherService.get(this.cipherId); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain), + ); + + // Update the initial state of the submit button + if (this.submitBtn) { + this.submitBtn.disabled = !this.attachmentForm.valid; + } + } + + ngAfterViewInit(): void { + this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((loading) => { + if (!this.submitBtn) { + return; + } + + this.submitBtn.loading = loading; + }); + + this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => { + if (!this.submitBtn) { + return; + } + + this.submitBtn.disabled = disabled; + }); + } + + /** Reference the `id` via the static property */ + get attachmentFormId(): string { + return CipherAttachmentsComponent.attachmentFormID; + } + + /** Updates the form value when a file is selected */ + onFileChange(event: Event): void { + const fileInputEl = event.target as HTMLInputElement; + + if (fileInputEl.files && fileInputEl.files.length > 0) { + this.attachmentForm.controls.file.setValue(fileInputEl.files[0]); + } + } + + /** Save the attachments to the cipher */ + submit = async () => { + const file = this.attachmentForm.value.file; + if (file === null) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("selectFile"), + }); + return; + } + + if (file.size > 524288000) { + // 500 MB + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("maxFileSize"), + }); + return; + } + + try { + this.cipherDomain = await this.cipherService.saveAttachmentWithServer( + this.cipherDomain, + file, + ); + + // re-decrypt the cipher to update the attachments + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain), + ); + + // Reset reactive form and input element + this.fileInput.nativeElement.value = ""; + this.attachmentForm.controls.file.setValue(null); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("attachmentSaved"), + }); + + this.onUploadSuccess.emit(); + } catch (e) { + this.logService.error(e); + } + }; + + /** Removes the attachment from the cipher */ + removeAttachment(attachment: AttachmentView) { + const index = this.cipher.attachments.indexOf(attachment); + + if (index > -1) { + this.cipher.attachments.splice(index, 1); + } + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.html b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.html new file mode 100644 index 00000000000..38ece650b72 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.html @@ -0,0 +1,9 @@ + diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.spec.ts new file mode 100644 index 00000000000..749093902d7 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.spec.ts @@ -0,0 +1,105 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { DeleteAttachmentComponent } from "./delete-attachment.component"; + +describe("DeleteAttachmentComponent", () => { + let component: DeleteAttachmentComponent; + let fixture: ComponentFixture; + const showToast = jest.fn(); + const attachment = { + id: "222-3333-4444", + url: "attachment-url", + fileName: "attachment-filename", + size: "1234", + } as AttachmentView; + + const deleteAttachmentWithServer = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + + beforeEach(async () => { + deleteAttachmentWithServer.mockClear(); + showToast.mockClear(); + openSimpleDialog.mockClear().mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [DeleteAttachmentComponent], + providers: [ + { + provide: CipherService, + useValue: { deleteAttachmentWithServer }, + }, + { + provide: ToastService, + useValue: { showToast }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: LogService, useValue: mock() }, + ], + }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DeleteAttachmentComponent); + component = fixture.componentInstance; + component.cipherId = "5555-444-3333"; + component.attachment = attachment; + fixture.detectChanges(); + }); + + it("renders delete button", () => { + const deleteButton = fixture.debugElement.query(By.css("button")); + + expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName"); + }); + + it("does not delete when the user cancels the dialog", async () => { + openSimpleDialog.mockResolvedValue(false); + + await component.delete(); + + expect(openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "deleteAttachment" }, + content: { key: "permanentlyDeleteAttachmentConfirmation" }, + type: "warning", + }); + + expect(deleteAttachmentWithServer).not.toHaveBeenCalled(); + }); + + it("deletes the attachment", async () => { + await component.delete(); + + expect(openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "deleteAttachment" }, + content: { key: "permanentlyDeleteAttachmentConfirmation" }, + type: "warning", + }); + + // Called with cipher id and attachment id + expect(deleteAttachmentWithServer).toHaveBeenCalledWith("5555-444-3333", "222-3333-4444"); + }); + + it("shows toast message on successful deletion", async () => { + await component.delete(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "deletedAttachment", + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.ts new file mode 100644 index 00000000000..932b2df2e17 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/delete-attachment/delete-attachment.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "app-delete-attachment", + templateUrl: "./delete-attachment.component.html", + imports: [AsyncActionsModule, CommonModule, JslibModule, ButtonModule, IconButtonModule], +}) +export class DeleteAttachmentComponent { + /** Id of the cipher associated with the attachment */ + @Input({ required: true }) cipherId: string; + + /** The attachment that is can be deleted */ + @Input({ required: true }) attachment: AttachmentView; + + /** Emits when the attachment is successfully deleted */ + @Output() onDeletionSuccess = new EventEmitter(); + + constructor( + private toastService: ToastService, + private i18nService: I18nService, + private cipherService: CipherService, + private logService: LogService, + private dialogService: DialogService, + ) {} + + delete = async () => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteAttachment" }, + content: { key: "permanentlyDeleteAttachmentConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.cipherService.deleteAttachmentWithServer(this.cipherId, this.attachment.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedAttachment"), + }); + + this.onDeletionSuccess.emit(); + } catch (e) { + this.logService.error(e); + } + }; +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.html b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.html new file mode 100644 index 00000000000..e6a20ba044b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.html @@ -0,0 +1,8 @@ + diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.spec.ts new file mode 100644 index 00000000000..45c9e7fb377 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.spec.ts @@ -0,0 +1,144 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { ToastService } from "../../../../../../../../../../libs/components/src/toast"; + +import { DownloadAttachmentComponent } from "./download-attachment.component"; + +class MockRequest { + constructor(public url: string) {} +} + +describe("DownloadAttachmentComponent", () => { + let component: DownloadAttachmentComponent; + let fixture: ComponentFixture; + const activeUserId$ = new BehaviorSubject("888-333-222-222"); + const showToast = jest.fn(); + const getAttachmentData = jest + .fn() + .mockResolvedValue({ url: "https://www.downloadattachement.com" }); + const download = jest.fn(); + + const attachment = { + id: "222-3333-4444", + url: "https://www.attachment.com", + fileName: "attachment-filename", + size: "1234", + } as AttachmentView; + + const cipherView = { + id: "5555-444-3333", + type: CipherType.Login, + name: "Test Login", + login: { + username: "username", + password: "password", + }, + } as CipherView; + + beforeEach(async () => { + showToast.mockClear(); + getAttachmentData.mockClear(); + download.mockClear(); + + await TestBed.configureTestingModule({ + imports: [DownloadAttachmentComponent], + providers: [ + { provide: EncryptService, useValue: mock() }, + { provide: CryptoService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: StateProvider, useValue: { activeUserId$ } }, + { provide: ToastService, useValue: { showToast } }, + { provide: ApiService, useValue: { getAttachmentData } }, + { provide: FileDownloadService, useValue: { download } }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DownloadAttachmentComponent); + component = fixture.componentInstance; + component.attachment = attachment; + component.cipher = cipherView; + fixture.detectChanges(); + }); + + it("renders delete button", () => { + const deleteButton = fixture.debugElement.query(By.css("button")); + + expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName"); + }); + + describe("download attachment", () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn().mockResolvedValue({}); + global.fetch = fetchMock; + // Request is not defined in the Jest runtime + // eslint-disable-next-line no-global-assign + Request = MockRequest as any; + }); + + it("uses the attachment url when available when getAttachmentData returns a 404", async () => { + getAttachmentData.mockRejectedValue(new ErrorResponse({}, 404)); + + await component.download(); + + expect(fetchMock).toHaveBeenCalledWith({ url: attachment.url }); + }); + + it("calls file download service with the attachment url", async () => { + getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); + fetchMock.mockResolvedValue({ status: 200 }); + EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue({}); + + await component.download(); + + expect(download).toHaveBeenCalledWith({ blobData: undefined, fileName: attachment.fileName }); + }); + + describe("errors", () => { + it("shows an error toast when fetch fails", async () => { + getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); + fetchMock.mockResolvedValue({ status: 500 }); + + await component.download(); + + expect(showToast).toHaveBeenCalledWith({ + message: "errorOccurred", + title: null, + variant: "error", + }); + }); + + it("shows an error toast when EncArrayBuffer fails", async () => { + getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); + fetchMock.mockResolvedValue({ status: 200 }); + EncArrayBuffer.fromResponse = jest.fn().mockRejectedValue({}); + + await component.download(); + + expect(showToast).toHaveBeenCalledWith({ + message: "errorOccurred", + title: null, + variant: "error", + }); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.ts new file mode 100644 index 00000000000..528695eab45 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/cipher-attachments/download-attachment/download-attachment.component.ts @@ -0,0 +1,104 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { NEVER, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "app-download-attachment", + templateUrl: "./download-attachment.component.html", + imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule], +}) +export class DownloadAttachmentComponent { + /** Attachment to download */ + @Input({ required: true }) attachment: AttachmentView; + + /** The cipher associated with the attachment */ + @Input({ required: true }) cipher: CipherView; + + /** The organization key if the cipher is associated with one */ + private orgKey: OrgKey | null = null; + + constructor( + private i18nService: I18nService, + private apiService: ApiService, + private fileDownloadService: FileDownloadService, + private toastService: ToastService, + private encryptService: EncryptService, + private stateProvider: StateProvider, + private cryptoService: CryptoService, + ) { + this.stateProvider.activeUserId$ + .pipe( + switchMap((userId) => (userId !== null ? this.cryptoService.orgKeys$(userId) : NEVER)), + takeUntilDestroyed(), + ) + .subscribe((data: Record | null) => { + if (data) { + this.orgKey = data[this.cipher.organizationId as OrganizationId]; + } + }); + } + + /** Download the attachment */ + download = async () => { + let url: string; + + try { + const attachmentDownloadResponse = await this.apiService.getAttachmentData( + this.cipher.id, + this.attachment.id, + ); + url = attachmentDownloadResponse.url; + } catch (e) { + if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { + url = this.attachment.url; + } else if (e instanceof ErrorResponse) { + throw new Error((e as ErrorResponse).getSingleMessage()); + } else { + throw e; + } + } + + const response = await fetch(new Request(url, { cache: "no-store" })); + if (response.status !== 200) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + return; + } + + try { + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = this.attachment.key != null ? this.attachment.key : this.orgKey; + const decBuf = await this.encryptService.decryptToBytes(encBuf, key); + this.fileDownloadService.download({ + fileName: this.attachment.fileName, + blobData: decBuf, + }); + } catch (e) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + } + }; +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html new file mode 100644 index 00000000000..6b2d8eaa033 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html @@ -0,0 +1,14 @@ + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts new file mode 100644 index 00000000000..a61c3eb6dd7 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -0,0 +1,166 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { BehaviorSubject } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ToastService } from "@bitwarden/components"; + +import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils"; + +import { OpenAttachmentsComponent } from "./open-attachments.component"; + +describe("OpenAttachmentsComponent", () => { + let component: OpenAttachmentsComponent; + let fixture: ComponentFixture; + let router: Router; + const showToast = jest.fn(); + const hasPremiumFromAnySource$ = new BehaviorSubject(true); + const openCurrentPagePopout = jest + .spyOn(BrowserPopupUtils, "openCurrentPagePopout") + .mockResolvedValue(null); + const cipherView = { + id: "5555-444-3333", + type: CipherType.Login, + name: "Test Login", + login: { + username: "username", + password: "password", + }, + } as CipherView; + + const cipherDomain = { + decrypt: () => cipherView, + }; + + const org = { + name: "Test Org", + productTierType: ProductTierType.Enterprise, + } as Organization; + + const getCipher = jest.fn().mockResolvedValue(cipherDomain); + const getOrganization = jest.fn().mockResolvedValue(org); + + beforeEach(async () => { + openCurrentPagePopout.mockClear(); + getCipher.mockClear(); + showToast.mockClear(); + getOrganization.mockClear(); + + await TestBed.configureTestingModule({ + imports: [OpenAttachmentsComponent, RouterTestingModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$ } }, + { + provide: CipherService, + useValue: { + get: getCipher, + getKeyForCipherKeyDecryption: () => Promise.resolve(null), + }, + }, + { + provide: ToastService, + useValue: { showToast }, + }, + { + provide: OrganizationService, + useValue: { get: getOrganization }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OpenAttachmentsComponent); + component = fixture.componentInstance; + component.cipherId = "5555-444-3333" as CipherId; + router = TestBed.inject(Router); + jest.spyOn(router, "navigate").mockResolvedValue(true); + fixture.detectChanges(); + }); + + it("opens attachments in new popout", async () => { + component.openAttachmentsInPopout = true; + + await component.openAttachments(); + + expect(router.navigate).not.toHaveBeenCalled(); + expect(openCurrentPagePopout).toHaveBeenCalledWith( + window, + "http:/localhost//attachments?cipherId=5555-444-3333", + ); + }); + + it("opens attachments in same window", async () => { + component.openAttachmentsInPopout = false; + + await component.openAttachments(); + + expect(openCurrentPagePopout).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(["/attachments"], { + queryParams: { cipherId: "5555-444-3333" }, + }); + }); + + it("routes the user to the premium page when they cannot access premium features", async () => { + hasPremiumFromAnySource$.next(false); + + await component.openAttachments(); + + expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + }); + + describe("Free Orgs", () => { + beforeEach(() => { + component.cipherIsAPartOfFreeOrg = undefined; + }); + + it("sets `cipherIsAPartOfFreeOrg` to false when the cipher is not a part of an organization", async () => { + cipherView.organizationId = null; + + await component.ngOnInit(); + + expect(component.cipherIsAPartOfFreeOrg).toBe(false); + }); + + it("sets `cipherIsAPartOfFreeOrg` to true when the cipher is a part of a free organization", async () => { + cipherView.organizationId = "888-333-333"; + org.productTierType = ProductTierType.Free; + + await component.ngOnInit(); + + expect(component.cipherIsAPartOfFreeOrg).toBe(true); + }); + + it("sets `cipherIsAPartOfFreeOrg` to false when the organization is not free", async () => { + cipherView.organizationId = "888-333-333"; + org.productTierType = ProductTierType.Families; + + await component.ngOnInit(); + + expect(component.cipherIsAPartOfFreeOrg).toBe(false); + }); + + it("shows toast when the cipher is a part of a free org", async () => { + component.canAccessAttachments = true; + component.cipherIsAPartOfFreeOrg = true; + + await component.openAttachments(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + title: null, + message: "freeOrgsCannotUseAttachments", + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts new file mode 100644 index 00000000000..c203620ed61 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Router } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + BadgeModule, + CardComponent, + ItemModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils"; + +@Component({ + standalone: true, + selector: "app-open-attachments", + templateUrl: "./open-attachments.component.html", + imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule, CardComponent], +}) +export class OpenAttachmentsComponent implements OnInit { + /** Cipher `id` */ + @Input({ required: true }) cipherId: CipherId; + + /** True when the attachments window should be opened in a popout */ + openAttachmentsInPopout = BrowserPopupUtils.inPopup(window); + + /** True when the user has access to premium or h */ + canAccessAttachments: boolean; + + /** True when the cipher is a part of a free organization */ + cipherIsAPartOfFreeOrg: boolean; + + constructor( + private router: Router, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private cipherService: CipherService, + private organizationService: OrganizationService, + private toastService: ToastService, + private i18nService: I18nService, + ) { + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntilDestroyed()) + .subscribe((canAccessPremium) => { + this.canAccessAttachments = canAccessPremium; + }); + } + + async ngOnInit(): Promise { + const cipherDomain = await this.cipherService.get(this.cipherId); + const cipher = await cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain), + ); + + if (!cipher.organizationId) { + this.cipherIsAPartOfFreeOrg = false; + return; + } + + const org = await this.organizationService.get(cipher.organizationId); + + this.cipherIsAPartOfFreeOrg = org.productTierType === ProductTierType.Free; + } + + /** Routes the user to the attachments screen, if available */ + async openAttachments() { + if (!this.canAccessAttachments) { + await this.router.navigate(["/premium"]); + return; + } + + if (this.cipherIsAPartOfFreeOrg) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("freeOrgsCannotUseAttachments"), + }); + return; + } + + if (this.openAttachmentsInPopout) { + const destinationUrl = this.router + .createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipherId } }) + .toString(); + + const currentBaseUrl = window.location.href.replace(this.router.url, ""); + + await BrowserPopupUtils.openCurrentPagePopout(window, currentBaseUrl + destinationUrl); + } else { + await this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipherId } }); + } + } +}