mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[PM-9809] attachments v2 refactor (#10142)
* update attachments v2 view. using download attachment component. remove excess code. Refactor location of attachments v2
This commit is contained in:
@@ -12,12 +12,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { ButtonComponent } from "@bitwarden/components";
|
||||
import { CipherAttachmentsComponent } from "@bitwarden/vault";
|
||||
|
||||
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,
|
||||
|
||||
@@ -8,14 +8,13 @@ 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 { CipherAttachmentsComponent } from "@bitwarden/vault";
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
|
||||
|
||||
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none">
|
||||
<li *ngFor="let attachment of cipher.attachments">
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
<span data-testid="file-name">{{ attachment.fileName }}</span>
|
||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||
</bit-item-content>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<app-download-attachment
|
||||
[cipher]="cipher"
|
||||
[attachment]="attachment"
|
||||
></app-download-attachment>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[cipherId]="cipher.id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
|
||||
<bit-card>
|
||||
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
|
||||
{{ "addAttachment" | i18n }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<!-- Input elements are notoriously difficult to style, --->
|
||||
<!-- The native `<input>` will be used for screen readers -->
|
||||
<!-- Visual & keyboard users will interact with the styled button element -->
|
||||
<input
|
||||
#fileInput
|
||||
class="tw-sr-only"
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
aria-describedby="fileHelp"
|
||||
tabindex="-1"
|
||||
required
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
(click)="fileInput.click()"
|
||||
class="tw-whitespace-nowrap"
|
||||
>
|
||||
{{ "chooseFile" | i18n }}
|
||||
</button>
|
||||
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
|
||||
{{
|
||||
this.attachmentForm.controls.file?.value
|
||||
? this.attachmentForm.controls.file.value.name
|
||||
: ("noFileChosen" | i18n)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
|
||||
{{ "maxFileSizeSansPunctuation" | i18n }}
|
||||
</p>
|
||||
</bit-card>
|
||||
</form>
|
||||
@@ -1,273 +0,0 @@
|
||||
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<CipherAttachmentsComponent>;
|
||||
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<LogService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
],
|
||||
})
|
||||
.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);
|
||||
});
|
||||
|
||||
it("sets testids for automation testing", () => {
|
||||
const attachment = {
|
||||
id: "1234-5678",
|
||||
fileName: "test file.txt",
|
||||
sizeName: "244.2 KB",
|
||||
} as AttachmentView;
|
||||
|
||||
component.cipher.attachments = [attachment];
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const fileName = fixture.debugElement.query(By.css('[data-testid="file-name"]'));
|
||||
const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]'));
|
||||
|
||||
expect(fileName.nativeElement.textContent).toEqual(attachment.fileName);
|
||||
expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
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<File | null>;
|
||||
}>;
|
||||
|
||||
@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<HTMLInputElement>;
|
||||
|
||||
/** 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<void>();
|
||||
|
||||
cipher: CipherView;
|
||||
|
||||
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
|
||||
file: new FormControl<File>(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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<button
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
size="small"
|
||||
type="button"
|
||||
class="tw-border-none"
|
||||
[appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName"
|
||||
[bitAction]="delete"
|
||||
></button>
|
||||
@@ -1,105 +0,0 @@
|
||||
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<DeleteAttachmentComponent>;
|
||||
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<LogService>() },
|
||||
],
|
||||
})
|
||||
.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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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<void>();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<button
|
||||
[bitAction]="download"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName"
|
||||
></button>
|
||||
@@ -1,144 +0,0 @@
|
||||
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<DownloadAttachmentComponent>;
|
||||
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<EncryptService>() },
|
||||
{ provide: CryptoService, useValue: mock<CryptoService>() },
|
||||
{ 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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<OrganizationId, OrgKey> | 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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user