1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 01:03:35 +00:00

[PM-22750] Reimplement fix old attachment logic (#17689)

* [PM-22750] Add upgradeOldCipherAttachment method to CipherService

* [PM-22750] Refactor download attachment component to use signals

* [PM-22750] Better download url handling

* [PM-22750] Cleanup upgradeOldCipherAttachments method

* [PM-22750] Refactor cipher-attachments.component to use Signals and OnPush

* [PM-22750] Use the correct legacy decryption key for attachments without their own content encryption key

* [PM-22750] Add fix attachment button back to attachments component

* [PM-22750] Fix newly added output signals

* [PM-22750] Fix failing test due to signal refactor

* [PM-22750] Update copy
This commit is contained in:
Shane Melton
2025-12-08 09:14:41 -08:00
committed by GitHub
parent f89c9b0f84
commit 9f5dab05a2
12 changed files with 491 additions and 260 deletions

View File

@@ -1457,6 +1457,15 @@
"attachmentSaved": { "attachmentSaved": {
"message": "Attachment saved" "message": "Attachment saved"
}, },
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"file": { "file": {
"message": "File" "message": "File"
}, },

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core"; import { Component, input, ChangeDetectionStrategy } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
@@ -25,31 +25,23 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach
import { AttachmentsV2Component } from "./attachments-v2.component"; import { AttachmentsV2Component } from "./attachments-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "popup-header", selector: "popup-header",
template: `<ng-content></ng-content>`, template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
class MockPopupHeaderComponent { class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly pageTitle = input<string>();
// eslint-disable-next-line @angular-eslint/prefer-signals readonly backAction = input<() => void>();
@Input() pageTitle: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void;
} }
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "popup-footer", selector: "popup-footer",
template: `<ng-content></ng-content>`, template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
class MockPopupFooterComponent { class MockPopupFooterComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly pageTitle = input<string>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
} }
describe("AttachmentsV2Component", () => { describe("AttachmentsV2Component", () => {
@@ -120,7 +112,7 @@ describe("AttachmentsV2Component", () => {
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
.componentInstance; .componentInstance;
expect(cipherAttachment.submitBtn).toEqual(submitBtn); expect(cipherAttachment.submitBtn()).toEqual(submitBtn);
}); });
it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => { it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => {

View File

@@ -708,6 +708,15 @@
"addAttachment": { "addAttachment": {
"message": "Add attachment" "message": "Add attachment"
}, },
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"maxFileSizeSansPunctuation": { "maxFileSizeSansPunctuation": {
"message": "Maximum file size is 500 MB" "message": "Maximum file size is 500 MB"
}, },

View File

@@ -5173,6 +5173,15 @@
"message": "Fix", "message": "Fix",
"description": "This is a verb. ex. 'Fix The Car'" "description": "This is a verb. ex. 'Fix The Car'"
}, },
"fixEncryption": {
"message": "Fix encryption"
},
"fixEncryptionTooltip": {
"message": "This file is using an outdated encryption method."
},
"attachmentUpdated": {
"message": "Attachment updated"
},
"oldAttachmentsNeedFixDesc": { "oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
}, },

View File

@@ -161,6 +161,17 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
userId: UserId, userId: UserId,
admin?: boolean, admin?: boolean,
): Promise<Cipher>; ): Promise<Cipher>;
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher - The cipher with old attachments to upgrade
* @param userId - The user ID
* @param attachmentId - If provided, only upgrade the attachment with this ID
*/
abstract upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView>;
/** /**
* Save the collections for a cipher with the server * Save the collections for a cipher with the server
* *
@@ -274,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response, response: Response,
userId: UserId, userId: UserId,
useLegacyDecryption?: boolean, useLegacyDecryption?: boolean,
): Promise<Uint8Array | null>; ): Promise<Uint8Array>;
/** /**
* Decrypts the full `CipherView` for a given `CipherViewLike`. * Decrypts the full `CipherView` for a given `CipherViewLike`.

View File

@@ -1656,12 +1656,14 @@ export class CipherService implements CipherServiceAbstraction {
const key = const key =
attachment.key != null attachment.key != null
? attachment.key ? attachment.key
: await firstValueFrom( : cipherDomain.organizationId
? await firstValueFrom(
this.keyService.orgKeys$(userId).pipe( this.keyService.orgKeys$(userId).pipe(
filterOutNullish(), filterOutNullish(),
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey), map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
), ),
); )
: await firstValueFrom(this.keyService.userKey$(userId).pipe(filterOutNullish()));
return await this.encryptService.decryptFileData(encBuf, key); return await this.encryptService.decryptFileData(encBuf, key);
} }
@@ -1829,6 +1831,95 @@ export class CipherService implements CipherServiceAbstraction {
} }
} }
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher
* @param userId
* @param attachmentId Optional specific attachment ID to upgrade. If not provided, all old attachments will be upgraded.
*/
async upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView> {
if (!cipher.hasOldAttachments) {
return cipher;
}
let cipherDomain = await this.get(cipher.id, userId);
for (const attachmentView of cipher.attachments) {
if (
attachmentView.key != null ||
(attachmentId != null && attachmentView.id !== attachmentId)
) {
continue;
}
try {
// 1. Get download URL
const downloadUrl = await this.getAttachmentDownloadUrl(cipher.id, attachmentView);
// 2. Download attachment data
const dataResponse = await this.apiService.nativeFetch(
new Request(downloadUrl, { cache: "no-store" }),
);
if (dataResponse.status !== 200) {
throw new Error(`Failed to download attachment. Status: ${dataResponse.status}`);
}
// 3. Decrypt the attachment
const decryptedBuffer = await this.getDecryptedAttachmentBuffer(
cipher.id as CipherId,
attachmentView,
dataResponse,
userId,
);
// 4. Re-upload with attachment key
cipherDomain = await this.saveAttachmentRawWithServer(
cipherDomain,
attachmentView.fileName,
decryptedBuffer,
userId,
);
// 5. Delete the old attachment
const cipherData = await this.deleteAttachmentWithServer(
cipher.id,
attachmentView.id,
userId,
);
cipherDomain = new Cipher(cipherData);
} catch (e) {
this.logService.error(`Failed to upgrade attachment ${attachmentView.id}`, e);
throw e;
}
}
return await this.decrypt(cipherDomain, userId);
}
private async getAttachmentDownloadUrl(
cipherId: string,
attachmentView: AttachmentView,
): Promise<string> {
try {
const attachmentResponse = await this.apiService.getAttachmentData(
cipherId,
attachmentView.id,
);
return attachmentResponse.url;
} catch (e) {
// Fall back to the attachment's stored URL
if (e instanceof ErrorResponse && e.statusCode === 404 && attachmentView.url) {
return attachmentView.url;
}
throw new Error(`Failed to get download URL for attachment ${attachmentView.id}`);
}
}
private async encryptObjProperty<V extends View, D extends Domain>( private async encryptObjProperty<V extends View, D extends Domain>(
model: V, model: V,
obj: D, obj: D,

View File

@@ -1,24 +1,47 @@
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2> <h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none tw-pl-0"> @if (cipher()?.attachments; as attachments) {
<li *ngFor="let attachment of cipher.attachments"> <ul aria-labelledby="attachments" class="tw-list-none tw-pl-0">
@for (attachment of attachments; track attachment.id) {
<li>
<bit-item> <bit-item>
<bit-item-content> <bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span> <span data-testid="file-name" [title]="attachment.fileName">{{
attachment.fileName
}}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
<i
*ngIf="attachment.key == null"
slot="default-trailing"
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
></i>
</bit-item-content> </bit-item-content>
<ng-container slot="end"> <ng-container slot="end">
<bit-item-action> <bit-item-action>
@if (attachment.key != null) {
<app-download-attachment <app-download-attachment
[admin]="admin && organization?.canEditAllCiphers" [admin]="admin() && organization()?.canEditAllCiphers"
[cipher]="cipher" [cipher]="cipher()"
[attachment]="attachment" [attachment]="attachment"
></app-download-attachment> ></app-download-attachment>
} @else {
<button
[bitAction]="fixOldAttachment(attachment)"
bitButton
buttonType="primary"
size="small"
type="button"
>
{{ "fixEncryption" | i18n }}
</button>
}
</bit-item-action> </bit-item-action>
<bit-item-action> <bit-item-action>
<app-delete-attachment <app-delete-attachment
[admin]="admin && organization?.canEditAllCiphers" [admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher.id" [cipherId]="cipher().id"
[attachment]="attachment" [attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)" (onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment> ></app-delete-attachment>
@@ -26,7 +49,9 @@
</ng-container> </ng-container>
</bit-item> </bit-item>
</li> </li>
}
</ul> </ul>
}
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit"> <form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
<bit-card> <bit-card>

View File

@@ -1,7 +1,8 @@
import { Component, Input } from "@angular/core"; import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -13,7 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
@@ -26,27 +27,21 @@ import { FakeAccountService, mockAccountServiceWith } from "../../../../../commo
import { CipherAttachmentsComponent } from "./cipher-attachments.component"; import { CipherAttachmentsComponent } from "./cipher-attachments.component";
import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component"; import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "app-download-attachment", selector: "app-download-attachment",
template: "", template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
class MockDownloadAttachmentComponent { class MockDownloadAttachmentComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly attachment = input<AttachmentView>();
// eslint-disable-next-line @angular-eslint/prefer-signals readonly cipher = input<CipherView>();
@Input() attachment: AttachmentView; readonly admin = input<boolean>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() cipher: CipherView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin: boolean = false;
} }
describe("CipherAttachmentsComponent", () => { describe("CipherAttachmentsComponent", () => {
let component: CipherAttachmentsComponent; let component: CipherAttachmentsComponent;
let fixture: ComponentFixture<CipherAttachmentsComponent>; let fixture: ComponentFixture<CipherAttachmentsComponent>;
let submitBtnFixture: ComponentFixture<ButtonComponent>;
const showToast = jest.fn(); const showToast = jest.fn();
const cipherView = { const cipherView = {
id: "5555-444-3333", id: "5555-444-3333",
@@ -63,17 +58,21 @@ describe("CipherAttachmentsComponent", () => {
}; };
const organization = new Organization(); const organization = new Organization();
organization.id = "org-123" as OrganizationId;
organization.type = OrganizationUserType.Admin; organization.type = OrganizationUserType.Admin;
organization.allowAdminAccessToAllCollectionItems = true; organization.allowAdminAccessToAllCollectionItems = true;
const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain); const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain);
const cipherServiceDecrypt = jest.fn().mockResolvedValue(cipherView);
const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain); const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain);
const mockUserId = Utils.newGuid() as UserId; const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const organizations$ = new BehaviorSubject<Organization[]>([organization]);
beforeEach(async () => { beforeEach(async () => {
cipherServiceGet.mockClear(); cipherServiceGet.mockClear();
cipherServiceDecrypt.mockClear().mockResolvedValue(cipherView);
showToast.mockClear(); showToast.mockClear();
saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain); saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain);
@@ -87,7 +86,7 @@ describe("CipherAttachmentsComponent", () => {
get: cipherServiceGet, get: cipherServiceGet,
saveAttachmentWithServer, saveAttachmentWithServer,
getKeyForCipherKeyDecryption: () => Promise.resolve(null), getKeyForCipherKeyDecryption: () => Promise.resolve(null),
decrypt: jest.fn().mockResolvedValue(cipherView), decrypt: cipherServiceDecrypt,
}, },
}, },
{ {
@@ -110,7 +109,9 @@ describe("CipherAttachmentsComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: {
organizations$: () => organizations$.asObservable(),
},
}, },
], ],
}) })
@@ -128,70 +129,67 @@ describe("CipherAttachmentsComponent", () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(CipherAttachmentsComponent); fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.cipherId = "5555-444-3333" as CipherId; submitBtnFixture = TestBed.createComponent(ButtonComponent);
component.submitBtn = TestBed.createComponent(ButtonComponent).componentInstance;
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.detectChanges(); fixture.detectChanges();
}); });
/**
* Helper to wait for the async initialization effect to complete
*/
async function waitForInitialization(): Promise<void> {
await fixture.whenStable();
fixture.detectChanges();
}
it("fetches cipherView using `cipherId`", async () => { it("fetches cipherView using `cipherId`", async () => {
await component.ngOnInit(); await waitForInitialization();
expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333", mockUserId); expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333", mockUserId);
expect(component.cipher).toEqual(cipherView);
}); });
it("sets testids for automation testing", () => { it("sets testids for automation testing", async () => {
const attachment = { const attachment = {
id: "1234-5678", id: "1234-5678",
fileName: "test file.txt", fileName: "test file.txt",
sizeName: "244.2 KB", sizeName: "244.2 KB",
} as AttachmentView; } as AttachmentView;
component.cipher.attachments = [attachment]; const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
// Create fresh fixture to pick up the mock
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges(); fixture.detectChanges();
await waitForInitialization();
const fileName = fixture.debugElement.query(By.css('[data-testid="file-name"]')); const fileName = fixture.debugElement.query(By.css('[data-testid="file-name"]'));
const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]')); const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]'));
expect(fileName.nativeElement.textContent).toEqual(attachment.fileName); expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName);
expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName); expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName);
}); });
describe("bitSubmit", () => { describe("bitSubmit", () => {
beforeEach(() => {
component.submitBtn.disabled.set(undefined);
component.submitBtn.loading.set(undefined);
});
it("updates sets initial state of the submit button", async () => { it("updates sets initial state of the submit button", async () => {
await component.ngOnInit(); // Create fresh fixture to properly test initial state
submitBtnFixture = TestBed.createComponent(ButtonComponent);
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
expect(component.submitBtn.disabled()).toBe(true); fixture = TestBed.createComponent(CipherAttachmentsComponent);
}); component = fixture.componentInstance;
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
it("sets submitBtn loading state", () => { await waitForInitialization();
jest.useFakeTimers();
component.bitSubmit.loading = true; expect(submitBtnFixture.componentInstance.disabled()).toBe(true);
jest.runAllTimers();
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);
}); });
}); });
@@ -199,7 +197,7 @@ describe("CipherAttachmentsComponent", () => {
let file: File; let file: File;
beforeEach(() => { beforeEach(() => {
component.submitBtn.disabled.set(undefined); submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
file = new File([""], "attachment.txt", { type: "text/plain" }); file = new File([""], "attachment.txt", { type: "text/plain" });
const inputElement = fixture.debugElement.query(By.css("input[type=file]")); const inputElement = fixture.debugElement.query(By.css("input[type=file]"));
@@ -215,11 +213,11 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("sets value of `file` control when input changes", () => { it("sets value of `file` control when input changes", () => {
expect(component.attachmentForm.controls.file.value.name).toEqual(file.name); expect(component.attachmentForm.controls.file.value?.name).toEqual(file.name);
}); });
it("updates disabled state of submit button", () => { it("updates disabled state of submit button", () => {
expect(component.submitBtn.disabled()).toBe(false); expect(submitBtnFixture.componentInstance.disabled()).toBe(false);
}); });
}); });
@@ -250,6 +248,8 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("shows error toast with server message when saveAttachmentWithServer fails", async () => { it("shows error toast with server message when saveAttachmentWithServer fails", async () => {
await waitForInitialization();
const file = { size: 100 } as File; const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file); component.attachmentForm.controls.file.setValue(file);
@@ -265,6 +265,8 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("shows error toast with fallback message when error has no message property", async () => { it("shows error toast with fallback message when error has no message property", async () => {
await waitForInitialization();
const file = { size: 100 } as File; const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file); component.attachmentForm.controls.file.setValue(file);
@@ -279,6 +281,8 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("shows error toast with string error message", async () => { it("shows error toast with string error message", async () => {
await waitForInitialization();
const file = { size: 100 } as File; const file = { size: 100 } as File;
component.attachmentForm.controls.file.setValue(file); component.attachmentForm.controls.file.setValue(file);
@@ -296,13 +300,27 @@ describe("CipherAttachmentsComponent", () => {
describe("success", () => { describe("success", () => {
const file = { size: 524287999 } as File; const file = { size: 524287999 } as File;
beforeEach(() => { async function setupWithOrganization(adminAccess: boolean): Promise<void> {
// Create fresh fixture with organization set before cipherId
organization.allowAdminAccessToAllCollectionItems = adminAccess;
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
submitBtnFixture = TestBed.createComponent(ButtonComponent);
// Set organizationId BEFORE cipherId so the effect picks it up
fixture.componentRef.setInput("organizationId", organization.id);
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges();
await waitForInitialization();
component.attachmentForm.controls.file.setValue(file); component.attachmentForm.controls.file.setValue(file);
component.organization = organization; }
});
it("calls `saveAttachmentWithServer` with admin=false when admin permission is false for organization", async () => { it("calls `saveAttachmentWithServer` with admin=false when admin permission is false for organization", async () => {
component.organization.allowAdminAccessToAllCollectionItems = false; await setupWithOrganization(false);
await component.submit(); await component.submit();
expect(saveAttachmentWithServer).toHaveBeenCalledWith( expect(saveAttachmentWithServer).toHaveBeenCalledWith(
@@ -314,13 +332,16 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("calls `saveAttachmentWithServer` with admin=true when using admin API", async () => { it("calls `saveAttachmentWithServer` with admin=true when using admin API", async () => {
component.organization.allowAdminAccessToAllCollectionItems = true; await setupWithOrganization(true);
await component.submit(); await component.submit();
expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId, true); expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId, true);
}); });
it("resets form and input values", async () => { it("resets form and input values", async () => {
await setupWithOrganization(true);
await component.submit(); await component.submit();
const fileInput = fixture.debugElement.query(By.css("input[type=file]")); const fileInput = fixture.debugElement.query(By.css("input[type=file]"));
@@ -330,16 +351,19 @@ describe("CipherAttachmentsComponent", () => {
}); });
it("shows success toast", async () => { it("shows success toast", async () => {
await setupWithOrganization(true);
await component.submit(); await component.submit();
expect(showToast).toHaveBeenCalledWith({ expect(showToast).toHaveBeenCalledWith({
variant: "success", variant: "success",
title: null,
message: "attachmentSaved", message: "attachmentSaved",
}); });
}); });
it('emits "onUploadSuccess"', async () => { it('emits "onUploadSuccess"', async () => {
await setupWithOrganization(true);
const emitSpy = jest.spyOn(component.onUploadSuccess, "emit"); const emitSpy = jest.spyOn(component.onUploadSuccess, "emit");
await component.submit(); await component.submit();
@@ -350,22 +374,36 @@ describe("CipherAttachmentsComponent", () => {
}); });
describe("removeAttachment", () => { describe("removeAttachment", () => {
const attachment = { id: "1234-5678" } as AttachmentView; const attachment = { id: "1234-5678", fileName: "test.txt" } as AttachmentView;
beforeEach(() => { it("removes attachment from cipher", async () => {
component.cipher.attachments = [attachment]; // Create a new fixture with cipher that has attachments
const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
// Create fresh fixture
fixture = TestBed.createComponent(CipherAttachmentsComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
fixture.detectChanges(); fixture.detectChanges();
});
it("removes attachment from cipher", () => { await waitForInitialization();
// Verify attachment is rendered
const attachmentsBefore = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
expect(attachmentsBefore.length).toEqual(1);
const deleteAttachmentComponent = fixture.debugElement.query( const deleteAttachmentComponent = fixture.debugElement.query(
By.directive(DeleteAttachmentComponent), By.directive(DeleteAttachmentComponent),
).componentInstance as DeleteAttachmentComponent; ).componentInstance as DeleteAttachmentComponent;
deleteAttachmentComponent.onDeletionSuccess.emit(); deleteAttachmentComponent.onDeletionSuccess.emit();
expect(component.cipher.attachments).toEqual([]); fixture.detectChanges();
// After removal, there should be no attachments displayed
const attachmentItems = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
expect(attachmentItems.length).toEqual(0);
}); });
}); });
}); });

View File

@@ -1,17 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { import {
AfterViewInit, ChangeDetectionStrategy,
Component, Component,
DestroyRef, DestroyRef,
ElementRef, ElementRef,
EventEmitter, effect,
Input,
OnInit,
Output,
ViewChild,
inject, inject,
input,
output,
signal,
viewChild,
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { import {
@@ -56,11 +54,10 @@ type CipherAttachmentForm = FormGroup<{
file: FormControl<File | null>; file: FormControl<File | null>;
}>; }>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "app-cipher-attachments", selector: "app-cipher-attachments",
templateUrl: "./cipher-attachments.component.html", templateUrl: "./cipher-attachments.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
AsyncActionsModule, AsyncActionsModule,
ButtonModule, ButtonModule,
@@ -74,70 +71,50 @@ type CipherAttachmentForm = FormGroup<{
DownloadAttachmentComponent, DownloadAttachmentComponent,
], ],
}) })
export class CipherAttachmentsComponent implements OnInit, AfterViewInit { export class CipherAttachmentsComponent {
/** `id` associated with the form element */ /** `id` associated with the form element */
static attachmentFormID = "attachmentForm"; static attachmentFormID = "attachmentForm";
/** Reference to the file HTMLInputElement */ /** Reference to the file HTMLInputElement */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals private readonly fileInput = viewChild("fileInput", { read: ElementRef<HTMLInputElement> });
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef<HTMLInputElement>;
/** Reference to the BitSubmitDirective */ /** Reference to the BitSubmitDirective */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly bitSubmit = viewChild(BitSubmitDirective);
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective;
/** The `id` of the cipher in context */ /** The `id` of the cipher in context */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly cipherId = input.required<CipherId>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipherId: CipherId;
/** The organization ID if this cipher belongs to an organization */ /** The organization ID if this cipher belongs to an organization */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly organizationId = input<OrganizationId>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() organizationId?: OrganizationId;
/** Denotes if the action is occurring from within the admin console */ /** Denotes if the action is occurring from within the admin console */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly admin = input<boolean>(false);
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin: boolean = false;
/** An optional submit button, whose loading/disabled state will be tied to the form state. */ /** An optional submit button, whose loading/disabled state will be tied to the form state. */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly submitBtn = input<ButtonComponent>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() submitBtn?: ButtonComponent;
/** Emits when a file upload is started */ /** Emits when a file upload is started */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly onUploadStarted = output<void>();
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadStarted = new EventEmitter<void>();
/** Emits after a file has been successfully uploaded */ /** Emits after a file has been successfully uploaded */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly onUploadSuccess = output<void>();
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadSuccess = new EventEmitter<void>();
/** Emits when a file upload fails */ /** Emits when a file upload fails */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly onUploadFailed = output<void>();
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUploadFailed = new EventEmitter<void>();
/** Emits after a file has been successfully removed */ /** Emits after a file has been successfully removed */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly onRemoveSuccess = output<void>();
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onRemoveSuccess = new EventEmitter<void>();
organization: Organization; protected readonly organization = signal<Organization | null>(null);
cipher: CipherView; protected readonly cipher = signal<CipherView | null>(null);
attachmentForm: CipherAttachmentForm = this.formBuilder.group({ attachmentForm: CipherAttachmentForm = this.formBuilder.group({
file: new FormControl<File>(null, [Validators.required]), file: new FormControl<File | null>(null, [Validators.required]),
}); });
private cipherDomain: Cipher; private cipherDomain: Cipher | null = null;
private activeUserId: UserId; private activeUserId: UserId | null = null;
private destroy$ = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
@@ -150,43 +127,52 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
private organizationService: OrganizationService, private organizationService: OrganizationService,
) { ) {
this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => { this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => {
if (!this.submitBtn) { const btn = this.submitBtn();
if (!btn) {
return; return;
} }
this.submitBtn.disabled.set(status !== "VALID"); btn.disabled.set(status !== "VALID");
}); });
// Initialize data when cipherId input is available
effect(async () => {
const cipherId = this.cipherId();
if (!cipherId) {
return;
} }
async ngOnInit(): Promise<void> {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// Get the organization to check admin permissions // Get the organization to check admin permissions
this.organization = await this.getOrganization(); this.organization.set(await this.getOrganization());
this.cipherDomain = await this.getCipher(this.cipherId); this.cipherDomain = await this.getCipher(cipherId);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId); if (this.cipherDomain && this.activeUserId) {
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
}
// Update the initial state of the submit button // Update the initial state of the submit button
if (this.submitBtn) { const btn = this.submitBtn();
this.submitBtn.disabled.set(!this.attachmentForm.valid); if (btn) {
btn.disabled.set(!this.attachmentForm.valid);
} }
}
ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((loading) => {
if (!this.submitBtn) {
return;
}
this.submitBtn.loading.set(loading);
}); });
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => { // Sync bitSubmit loading/disabled state with submitBtn
if (!this.submitBtn) { effect(() => {
const bitSubmit = this.bitSubmit();
const btn = this.submitBtn();
if (!bitSubmit || !btn) {
return; return;
} }
this.submitBtn.disabled.set(disabled); bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
btn.loading.set(loading);
});
bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
btn.disabled.set(disabled);
});
}); });
} }
@@ -209,7 +195,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
this.onUploadStarted.emit(); this.onUploadStarted.emit();
const file = this.attachmentForm.value.file; const file = this.attachmentForm.value.file;
if (file === null) { if (file == null) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: this.i18nService.t("errorOccurred"), title: this.i18nService.t("errorOccurred"),
@@ -228,24 +214,30 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
return; return;
} }
if (!this.cipherDomain || !this.activeUserId) {
return;
}
try { try {
this.cipherDomain = await this.cipherService.saveAttachmentWithServer( this.cipherDomain = await this.cipherService.saveAttachmentWithServer(
this.cipherDomain, this.cipherDomain,
file, file,
this.activeUserId, this.activeUserId,
this.organization?.canEditAllCiphers, this.organization()?.canEditAllCiphers,
); );
// re-decrypt the cipher to update the attachments // re-decrypt the cipher to update the attachments
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId); this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
// Reset reactive form and input element // Reset reactive form and input element
this.fileInput.nativeElement.value = ""; const fileInputEl = this.fileInput();
if (fileInputEl) {
fileInputEl.nativeElement.value = "";
}
this.attachmentForm.controls.file.setValue(null); this.attachmentForm.controls.file.setValue(null);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null,
message: this.i18nService.t("attachmentSaved"), message: this.i18nService.t("attachmentSaved"),
}); });
@@ -257,7 +249,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
let errorMessage = this.i18nService.t("unexpectedError"); let errorMessage = this.i18nService.t("unexpectedError");
if (typeof e === "string") { if (typeof e === "string") {
errorMessage = e; errorMessage = e;
} else if (e?.message) { } else if (e instanceof Error && e?.message) {
errorMessage = e.message; errorMessage = e.message;
} }
@@ -271,10 +263,19 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
/** Removes the attachment from the cipher */ /** Removes the attachment from the cipher */
removeAttachment(attachment: AttachmentView) { removeAttachment(attachment: AttachmentView) {
const index = this.cipher.attachments.indexOf(attachment); const currentCipher = this.cipher();
if (!currentCipher?.attachments) {
return;
}
const index = currentCipher.attachments.indexOf(attachment);
if (index > -1) { if (index > -1) {
this.cipher.attachments.splice(index, 1); currentCipher.attachments.splice(index, 1);
// Trigger signal update by creating a new reference
this.cipher.set(
Object.assign(Object.create(Object.getPrototypeOf(currentCipher)), currentCipher),
);
} }
this.onRemoveSuccess.emit(); this.onRemoveSuccess.emit();
@@ -286,7 +287,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
* it will retrieve the cipher using the admin endpoint. * it will retrieve the cipher using the admin endpoint.
*/ */
private async getCipher(id: CipherId): Promise<Cipher | null> { private async getCipher(id: CipherId): Promise<Cipher | null> {
if (id == null) { if (id == null || !this.activeUserId) {
return null; return null;
} }
@@ -294,12 +295,13 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
const localCipher = await this.cipherService.get(id, this.activeUserId); const localCipher = await this.cipherService.get(id, this.activeUserId);
// If we got the cipher or there's no organization context, return the result // If we got the cipher or there's no organization context, return the result
if (localCipher != null || !this.organizationId) { if (localCipher != null || !this.organizationId()) {
return localCipher; return localCipher;
} }
// Only try the admin API if the user has admin permissions // Only try the admin API if the user has admin permissions
if (this.organization != null && this.organization.canEditAllCiphers) { const org = this.organization();
if (org != null && org.canEditAllCiphers) {
const cipherResponse = await this.apiService.getCipherAdmin(id); const cipherResponse = await this.apiService.getCipherAdmin(id);
const cipherData = new CipherData(cipherResponse); const cipherData = new CipherData(cipherResponse);
return new Cipher(cipherData); return new Cipher(cipherData);
@@ -312,7 +314,8 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
* Gets the organization for the given organization ID * Gets the organization for the given organization ID
*/ */
private async getOrganization(): Promise<Organization | null> { private async getOrganization(): Promise<Organization | null> {
if (!this.organizationId) { const orgId = this.organizationId();
if (!orgId || !this.activeUserId) {
return null; return null;
} }
@@ -320,6 +323,41 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
this.organizationService.organizations$(this.activeUserId), this.organizationService.organizations$(this.activeUserId),
); );
return organizations.find((o) => o.id === this.organizationId) || null; return organizations.find((o) => o.id === orgId) || null;
} }
protected fixOldAttachment = (attachment: AttachmentView) => {
return async () => {
const cipher = this.cipher();
const userId = this.activeUserId;
if (!attachment.id || !userId || !cipher) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
try {
const updatedCipher = await this.cipherService.upgradeOldCipherAttachments(
cipher,
userId,
attachment.id,
);
this.cipher.set(updatedCipher);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("attachmentUpdated"),
});
this.onUploadSuccess.emit();
} catch {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
}
};
};
} }

View File

@@ -1,9 +1,10 @@
@if (!isDecryptionFailure()) {
<button <button
*ngIf="!isDecryptionFailure"
[bitAction]="download" [bitAction]="download"
bitIconButton="bwi-download" bitIconButton="bwi-download"
buttonType="main" buttonType="main"
size="small" size="small"
type="button" type="button"
[label]="'downloadAttachmentName' | i18n: attachment.fileName" [label]="'downloadAttachmentName' | i18n: attachment().fileName"
></button> ></button>
}

View File

@@ -100,8 +100,8 @@ describe("DownloadAttachmentComponent", () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(DownloadAttachmentComponent); fixture = TestBed.createComponent(DownloadAttachmentComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
component.attachment = attachment; fixture.componentRef.setInput("attachment", attachment);
component.cipher = cipherView; fixture.componentRef.setInput("cipher", cipherView);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -123,7 +123,8 @@ describe("DownloadAttachmentComponent", () => {
}); });
it("hides download button when the attachment has decryption failure", () => { it("hides download button when the attachment has decryption failure", () => {
component.attachment.fileName = DECRYPT_ERROR; const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR };
fixture.componentRef.setInput("attachment", decryptFailureAttachment);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.query(By.css("button"))).toBeNull(); expect(fixture.debugElement.query(By.css("button"))).toBeNull();
@@ -156,7 +157,6 @@ describe("DownloadAttachmentComponent", () => {
expect(showToast).toHaveBeenCalledWith({ expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred", message: "errorOccurred",
title: null,
variant: "error", variant: "error",
}); });
}); });
@@ -172,7 +172,6 @@ describe("DownloadAttachmentComponent", () => {
expect(showToast).toHaveBeenCalledWith({ expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred", message: "errorOccurred",
title: null,
variant: "error", variant: "error",
}); });
}); });

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -17,38 +15,27 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components"; import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "app-download-attachment", selector: "app-download-attachment",
templateUrl: "./download-attachment.component.html", templateUrl: "./download-attachment.component.html",
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule], imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DownloadAttachmentComponent { export class DownloadAttachmentComponent {
/** Attachment to download */ /** Attachment to download */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly attachment = input.required<AttachmentView>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) attachment: AttachmentView;
/** The cipher associated with the attachment */ /** The cipher associated with the attachment */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly cipher = input.required<CipherView>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipher: CipherView;
// When in view mode, we will want to check for the master password reprompt /** When in view mode, we will want to check for the master password reprompt */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly checkPwReprompt = input<boolean>(false);
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() checkPwReprompt?: boolean = false;
// Required for fetching attachment data when viewed from cipher via emergency access /** Required for fetching attachment data when viewed from cipher via emergency access */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly emergencyAccessId = input<EmergencyAccessId>();
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() emergencyAccessId?: EmergencyAccessId;
/** When owners/admins can mange all items and when accessing from the admin console, use the admin endpoint */ /** When owners/admins can manage all items and when accessing from the admin console, use the admin endpoint */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals readonly admin = input<boolean>(false);
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() admin?: boolean = false;
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
@@ -59,26 +46,36 @@ export class DownloadAttachmentComponent {
private cipherService: CipherService, private cipherService: CipherService,
) {} ) {}
protected get isDecryptionFailure(): boolean { protected readonly isDecryptionFailure = computed(
return this.attachment.fileName === DECRYPT_ERROR; () => this.attachment().fileName === DECRYPT_ERROR,
} );
/** Download the attachment */ /** Download the attachment */
download = async () => { download = async () => {
let url: string; const attachment = this.attachment();
const cipher = this.cipher();
let url: string | undefined;
if (!attachment.id) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
try { try {
const attachmentDownloadResponse = this.admin const attachmentDownloadResponse = this.admin()
? await this.apiService.getAttachmentDataAdmin(this.cipher.id, this.attachment.id) ? await this.apiService.getAttachmentDataAdmin(cipher.id, attachment.id)
: await this.apiService.getAttachmentData( : await this.apiService.getAttachmentData(
this.cipher.id, cipher.id,
this.attachment.id, attachment.id,
this.emergencyAccessId, this.emergencyAccessId(),
); );
url = attachmentDownloadResponse.url; url = attachmentDownloadResponse.url;
} catch (e) { } catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = this.attachment.url; url = attachment.url;
} else if (e instanceof ErrorResponse) { } else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage()); throw new Error((e as ErrorResponse).getSingleMessage());
} else { } else {
@@ -86,11 +83,18 @@ export class DownloadAttachmentComponent {
} }
} }
if (!url) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
const response = await fetch(new Request(url, { cache: "no-store" })); const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) { if (response.status !== 200) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"), message: this.i18nService.t("errorOccurred"),
}); });
return; return;
@@ -99,26 +103,31 @@ export class DownloadAttachmentComponent {
try { try {
const userId = await firstValueFrom(this.stateProvider.activeUserId$); const userId = await firstValueFrom(this.stateProvider.activeUserId$);
if (!userId || !attachment.fileName) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
return;
}
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipher.id as CipherId, cipher.id as CipherId,
this.attachment, attachment,
response, response,
userId, userId,
// When the emergency access ID is present, the cipher is being viewed via emergency access. // When the emergency access ID is present, the cipher is being viewed via emergency access.
// Force legacy decryption in these cases. // Force legacy decryption in these cases.
this.emergencyAccessId ? true : false, Boolean(this.emergencyAccessId()),
); );
this.fileDownloadService.download({ this.fileDownloadService.download({
fileName: this.attachment.fileName, fileName: attachment.fileName,
blobData: decBuf, blobData: decBuf,
}); });
// FIXME: Remove when updating file. Eslint update } catch {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"), message: this.i18nService.t("errorOccurred"),
}); });
} }