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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
this.keyService.orgKeys$(userId).pipe(
|
? await firstValueFrom(
|
||||||
filterOutNullish(),
|
this.keyService.orgKeys$(userId).pipe(
|
||||||
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
|
filterOutNullish(),
|
||||||
),
|
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,
|
||||||
|
|||||||
@@ -1,32 +1,57 @@
|
|||||||
<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">
|
||||||
<bit-item>
|
@for (attachment of attachments; track attachment.id) {
|
||||||
<bit-item-content>
|
<li>
|
||||||
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
|
<bit-item>
|
||||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
<bit-item-content>
|
||||||
</bit-item-content>
|
<span data-testid="file-name" [title]="attachment.fileName">{{
|
||||||
<ng-container slot="end">
|
attachment.fileName
|
||||||
<bit-item-action>
|
}}</span>
|
||||||
<app-download-attachment
|
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||||
[admin]="admin && organization?.canEditAllCiphers"
|
<i
|
||||||
[cipher]="cipher"
|
*ngIf="attachment.key == null"
|
||||||
[attachment]="attachment"
|
slot="default-trailing"
|
||||||
></app-download-attachment>
|
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
|
||||||
</bit-item-action>
|
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
|
||||||
<bit-item-action>
|
></i>
|
||||||
<app-delete-attachment
|
</bit-item-content>
|
||||||
[admin]="admin && organization?.canEditAllCiphers"
|
|
||||||
[cipherId]="cipher.id"
|
<ng-container slot="end">
|
||||||
[attachment]="attachment"
|
<bit-item-action>
|
||||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
@if (attachment.key != null) {
|
||||||
></app-delete-attachment>
|
<app-download-attachment
|
||||||
</bit-item-action>
|
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||||
</ng-container>
|
[cipher]="cipher()"
|
||||||
</bit-item>
|
[attachment]="attachment"
|
||||||
</li>
|
></app-download-attachment>
|
||||||
</ul>
|
} @else {
|
||||||
|
<button
|
||||||
|
[bitAction]="fixOldAttachment(attachment)"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ "fixEncryption" | i18n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</bit-item-action>
|
||||||
|
<bit-item-action>
|
||||||
|
<app-delete-attachment
|
||||||
|
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||||
|
[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">
|
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
|
||||||
<bit-card>
|
<bit-card>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
|
||||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
|
||||||
// Get the organization to check admin permissions
|
|
||||||
this.organization = await this.getOrganization();
|
|
||||||
this.cipherDomain = await this.getCipher(this.cipherId);
|
|
||||||
|
|
||||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId);
|
|
||||||
|
|
||||||
// Update the initial state of the submit button
|
|
||||||
if (this.submitBtn) {
|
|
||||||
this.submitBtn.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) => {
|
// Initialize data when cipherId input is available
|
||||||
if (!this.submitBtn) {
|
effect(async () => {
|
||||||
|
const cipherId = this.cipherId();
|
||||||
|
if (!cipherId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.submitBtn.disabled.set(disabled);
|
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
|
// Get the organization to check admin permissions
|
||||||
|
this.organization.set(await this.getOrganization());
|
||||||
|
this.cipherDomain = await this.getCipher(cipherId);
|
||||||
|
|
||||||
|
if (this.cipherDomain && this.activeUserId) {
|
||||||
|
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the initial state of the submit button
|
||||||
|
const btn = this.submitBtn();
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled.set(!this.attachmentForm.valid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync bitSubmit loading/disabled state with submitBtn
|
||||||
|
effect(() => {
|
||||||
|
const bitSubmit = this.bitSubmit();
|
||||||
|
const btn = this.submitBtn();
|
||||||
|
if (!bitSubmit || !btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<button
|
@if (!isDecryptionFailure()) {
|
||||||
*ngIf="!isDecryptionFailure"
|
<button
|
||||||
[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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user