1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +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:
Jason Ng
2024-07-23 13:27:39 -04:00
committed by GitHub
parent decc7a3031
commit 6041c460b7
19 changed files with 126 additions and 196 deletions

View File

@@ -0,0 +1,72 @@
<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>

View File

@@ -0,0 +1,273 @@
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 { DownloadAttachmentComponent } from "@bitwarden/vault";
import { CipherAttachmentsComponent } from "./cipher-attachments.component";
import { DeleteAttachmentComponent } from "./delete-attachment/delete-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([]);
});
});
});

View File

@@ -0,0 +1,212 @@
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 { DownloadAttachmentComponent } from "../../../components/download-attachment/download-attachment.component";
import { DeleteAttachmentComponent } from "./delete-attachment/delete-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);
}
}
}

View File

@@ -0,0 +1,9 @@
<button
bitIconButton="bwi-trash"
buttonType="danger"
size="small"
type="button"
class="tw-border-none"
[appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName"
[bitAction]="delete"
></button>

View File

@@ -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<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",
});
});
});

View File

@@ -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<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);
}
};
}

View File

@@ -0,0 +1,22 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "attachments" | i18n }}</h2>
</bit-section-header>
<bit-item-group>
<bit-item *ngFor="let attachment of cipher.attachments">
<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"
[checkPwReprompt]="true"
></app-download-attachment>
</bit-item-action>
</ng-container>
</bit-item>
</bit-item-group>
</bit-section>

View File

@@ -0,0 +1,73 @@
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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
import { DownloadAttachmentComponent } from "../../components/download-attachment/download-attachment.component";
@Component({
selector: "app-attachments-v2-view",
templateUrl: "attachments-v2-view.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
DownloadAttachmentComponent,
],
})
export class AttachmentsV2ViewComponent {
@Input() cipher: CipherView;
canAccessPremium: boolean;
orgKey: OrgKey;
constructor(
private cryptoService: CryptoService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private stateProvider: StateProvider,
) {
this.subscribeToHasPremiumCheck();
this.subscribeToOrgKey();
}
subscribeToHasPremiumCheck() {
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntilDestroyed())
.subscribe((data) => {
this.canAccessPremium = data;
});
}
subscribeToOrgKey() {
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];
}
});
}
}

View File

@@ -1,32 +0,0 @@
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "attachments" | i18n }}</h2>
</bit-section-header>
<bit-item-group>
<bit-item *ngFor="let attachment of cipher.attachments">
<div slot="start" class="tw-py-4 tw-px-3">
<h3>
{{ attachment.fileName }}
</h3>
<div class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
{{ attachment.sizeName }}
</div>
</div>
<div class="tw-flex tw-items-center" (click)="downloadAttachment(attachment)" slot="end">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[appA11yTitle]="'downloadAttachment' | i18n: attachment.fileName"
*ngIf="!$any(attachment).downloading"
></button>
<button
type="button"
bitIconButton="bwi-spinner bwi-spin"
size="small"
*ngIf="$any(attachment).downloading"
></button>
</div>
</bit-item>
</bit-item-group>
</bit-section>

View File

@@ -20,7 +20,7 @@
<!-- ATTACHMENTS SECTION -->
<ng-container *ngIf="cipher.attachments">
<app-attachments-v2 [cipher]="cipher"> </app-attachments-v2>
<app-attachments-v2-view [cipher]="cipher"> </app-attachments-v2-view>
</ng-container>
<!-- ITEM HISTORY SECTION -->

View File

@@ -18,7 +18,7 @@ import { PopupHeaderComponent } from "../../../../apps/browser/src/platform/popu
import { PopupPageComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-page.component";
import { AdditionalInformationComponent } from "./additional-information/additional-information.component";
import { AttachmentsV2Component } from "./attachments/attachments-v2.component";
import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component";
import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component";
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
@@ -36,7 +36,7 @@ import { ItemHistoryV2Component } from "./item-history/item-history-v2.component
PopupFooterComponent,
ItemDetailsV2Component,
AdditionalInformationComponent,
AttachmentsV2Component,
AttachmentsV2ViewComponent,
ItemHistoryV2Component,
CustomFieldV2Component,
],

View File

@@ -1 +1,2 @@
export * from "./cipher-view.component";
export { CipherAttachmentsComponent } from "../cipher-form/components/attachments/cipher-attachments.component";

View File

@@ -0,0 +1,8 @@
<button
[bitAction]="download"
bitIconButton="bwi-download"
buttonType="main"
size="small"
type="button"
[appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName"
></button>

View File

@@ -0,0 +1,146 @@
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 "@bitwarden/components";
import { PasswordRepromptService } from "../../services/password-reprompt.service";
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 } },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
],
}).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",
});
});
});
});
});

View File

@@ -5,7 +5,6 @@ import { NEVER, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
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";
@@ -15,65 +14,46 @@ import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-arr
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 {
ToastService,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "../../services/password-reprompt.service";
@Component({
selector: "app-attachments-v2",
templateUrl: "attachments-v2.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
selector: "app-download-attachment",
templateUrl: "./download-attachment.component.html",
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
})
export class AttachmentsV2Component {
@Input() cipher: CipherView;
export class DownloadAttachmentComponent {
/** Attachment to download */
@Input({ required: true }) attachment: AttachmentView;
/** The cipher associated with the attachment */
@Input({ required: true }) cipher: CipherView;
// When in view mode, we will want to check for the master password reprompt
@Input() checkPwReprompt?: boolean = false;
/** The organization key if the cipher is associated with one */
private orgKey: OrgKey | null = null;
canAccessPremium: boolean;
orgKey: OrgKey;
private passwordReprompted = false;
constructor(
private passwordRepromptService: PasswordRepromptService,
private i18nService: I18nService,
private apiService: ApiService,
private fileDownloadService: FileDownloadService,
private cryptoService: CryptoService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private stateProvider: StateProvider,
private encryptService: EncryptService,
private stateProvider: StateProvider,
private cryptoService: CryptoService,
private passwordRepromptService: PasswordRepromptService,
) {
this.subscribeToHasPremiumCheck();
this.subscribeToOrgKey();
}
subscribeToHasPremiumCheck() {
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntilDestroyed())
.subscribe((data) => {
this.canAccessPremium = data;
});
}
subscribeToOrgKey() {
this.stateProvider.activeUserId$
.pipe(
switchMap((userId) => (userId != null ? this.cryptoService.orgKeys$(userId) : NEVER)),
switchMap((userId) => (userId !== null ? this.cryptoService.orgKeys$(userId) : NEVER)),
takeUntilDestroyed(),
)
.subscribe((data: Record<OrganizationId, OrgKey> | null) => {
@@ -83,38 +63,28 @@ export class AttachmentsV2Component {
});
}
async downloadAttachment(attachment: any) {
this.passwordReprompted =
this.passwordReprompted ||
(await this.passwordRepromptService.passwordRepromptCheck(this.cipher));
if (!this.passwordReprompted) {
return;
}
const file = attachment as any;
if (file.downloading) {
return;
}
if (this.cipher.organizationId == null && !this.canAccessPremium) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("premiumRequired"),
message: this.i18nService.t("premiumRequiredDesc"),
});
return;
/** Download the attachment */
download = async () => {
if (this.checkPwReprompt) {
this.passwordReprompted =
this.passwordReprompted ||
(await this.passwordRepromptService.passwordRepromptCheck(this.cipher));
if (!this.passwordReprompted) {
return;
}
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
this.cipher.id,
attachment.id,
this.attachment.id,
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = attachment.url;
url = this.attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
@@ -122,7 +92,6 @@ export class AttachmentsV2Component {
}
}
file.downloading = true;
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
@@ -130,16 +99,15 @@ export class AttachmentsV2Component {
title: null,
message: this.i18nService.t("errorOccurred"),
});
file.downloading = false;
return;
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key = attachment.key != null ? attachment.key : this.orgKey;
const key = this.attachment.key != null ? this.attachment.key : this.orgKey;
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
this.fileDownloadService.download({
fileName: attachment.fileName,
fileName: this.attachment.fileName,
blobData: decBuf,
});
} catch (e) {
@@ -149,7 +117,5 @@ export class AttachmentsV2Component {
message: this.i18nService.t("errorOccurred"),
});
}
file.downloading = false;
}
};
}

View File

@@ -9,3 +9,5 @@ export {
CollectionAssignmentParams,
CollectionAssignmentResult,
} from "./components/assign-collections.component";
export { DownloadAttachmentComponent } from "./components/download-attachment/download-attachment.component";