mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM-7897] Attachments - Part 1 (#9715)
* add v2 attachments page * add add attachment fields * add file upload UI * move cipher-attachments to a new component * load cipher and add initial submit * add list of existing attachments * fix incorrect toast usage * integrate with bit submit states * add new max file translation without the period * refactor attachments v2 component * remove default list styles * add tests for attachments components * use `CipherId` type * pass submit button reference to the underlying form * remove bitFormButton * [PM-7897] Attachments Part 2 (#9755) * make `isNew` param optional * emit success output after upload * navigate the user to the edit screen after an upload * allow for the deletion of an attachment * add download attachment component to attachments view * implement base attachment link * add premium redirect * show specific error message for free organizations * make open-attachments a button so it is keyboard accessible * fix lint error * use bitItem * using bitAction rather than standalone loading/deleting value * remove extra title, unneeded because of the appA11yTitle usage * use `replaceUrl` to avoid the back button going to the attachments page * use bit-item for consistency * show error when a user tries to open an attachment that is a part of a free org * add `CipherId` type for failed builds
This commit is contained in:
@@ -3498,6 +3498,42 @@
|
||||
"contactYourOrgAdmin": {
|
||||
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance."
|
||||
},
|
||||
"upload": {
|
||||
"message": "Upload"
|
||||
},
|
||||
"addAttachment":{
|
||||
"message": "Add attachment"
|
||||
},
|
||||
"maxFileSizeSansPunctuation": {
|
||||
"message": "Maximum file size is 500 MB"
|
||||
},
|
||||
"deleteAttachmentName": {
|
||||
"message": "Delete attachment $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "Attachment Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadAttachmentName": {
|
||||
"message": "Download $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "Attachment Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permanentlyDeleteAttachmentConfirmation": {
|
||||
"message": "Are you sure you want to permanently delete this attachment?"
|
||||
},
|
||||
"premium": {
|
||||
"message": "Premium"
|
||||
},
|
||||
"freeOrgsCannotUseAttachments": {
|
||||
"message": "Free organizations cannot use attachments"
|
||||
},
|
||||
"filters": {
|
||||
"message": "Filters"
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items
|
||||
import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
|
||||
import { ViewComponent } from "../vault/popup/components/vault/view.component";
|
||||
import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component";
|
||||
import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component";
|
||||
import { AppearanceComponent } from "../vault/popup/settings/appearance.component";
|
||||
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
|
||||
import { FoldersComponent } from "../vault/popup/settings/folders.component";
|
||||
@@ -230,12 +231,11 @@ const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
data: { state: "collections" },
|
||||
},
|
||||
{
|
||||
...extensionRefreshSwap(AttachmentsComponent, AttachmentsV2Component, {
|
||||
path: "attachments",
|
||||
component: AttachmentsComponent,
|
||||
canActivate: [AuthGuard],
|
||||
data: { state: "attachments" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: "generator",
|
||||
component: GeneratorComponent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<popup-page>
|
||||
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
|
||||
|
||||
<app-open-attachments *ngIf="isEdit" [cipherId]="cipherId"></app-open-attachments>
|
||||
|
||||
<popup-footer slot="footer">
|
||||
<button bitButton type="button" buttonType="primary">
|
||||
{{ "save" | i18n }}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { SearchModule, ButtonModule } from "@bitwarden/components";
|
||||
|
||||
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-add-edit-v2",
|
||||
@@ -23,6 +25,7 @@ import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-p
|
||||
JslibModule,
|
||||
FormsModule,
|
||||
ButtonModule,
|
||||
OpenAttachmentsComponent,
|
||||
PopupPageComponent,
|
||||
PopupHeaderComponent,
|
||||
PopupFooterComponent,
|
||||
@@ -30,6 +33,8 @@ import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-p
|
||||
})
|
||||
export class AddEditV2Component {
|
||||
headerText: string;
|
||||
cipherId: CipherId;
|
||||
isEdit: boolean = false;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -40,9 +45,11 @@ export class AddEditV2Component {
|
||||
|
||||
subscribeToParams(): void {
|
||||
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const isNew = params.isNew.toLowerCase() === "true";
|
||||
const isNew = params.isNew?.toLowerCase() === "true";
|
||||
const cipherType = parseInt(params.type);
|
||||
|
||||
this.isEdit = !isNew;
|
||||
this.cipherId = params.cipherId;
|
||||
this.headerText = this.setHeader(isNew, cipherType);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<popup-page>
|
||||
<popup-header slot="header" [pageTitle]="'attachments' | i18n" showBackButton>
|
||||
<app-pop-out slot="end" />
|
||||
</popup-header>
|
||||
|
||||
<app-cipher-attachments
|
||||
*ngIf="cipherId"
|
||||
[cipherId]="cipherId"
|
||||
[submitBtn]="submitButton"
|
||||
(onUploadSuccess)="navigateToEditScreen()"
|
||||
></app-cipher-attachments>
|
||||
|
||||
<popup-footer slot="footer">
|
||||
<button
|
||||
#submitButton
|
||||
bitButton
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
[attr.form]="attachmentFormId"
|
||||
>
|
||||
{{ "upload" | i18n }}
|
||||
</button>
|
||||
</popup-footer>
|
||||
</popup-page>
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { ButtonComponent } from "@bitwarden/components";
|
||||
|
||||
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||
|
||||
import { AttachmentsV2Component } from "./attachments-v2.component";
|
||||
import { CipherAttachmentsComponent } from "./cipher-attachments/cipher-attachments.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "popup-header",
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
class MockPopupHeaderComponent {
|
||||
@Input() pageTitle: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "popup-footer",
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
class MockPopupFooterComponent {
|
||||
@Input() pageTitle: string;
|
||||
}
|
||||
|
||||
describe("AttachmentsV2Component", () => {
|
||||
let component: AttachmentsV2Component;
|
||||
let fixture: ComponentFixture<AttachmentsV2Component>;
|
||||
const queryParams = new BehaviorSubject<{ cipherId: string }>({ cipherId: "5555-444-3333" });
|
||||
let cipherAttachment: CipherAttachmentsComponent;
|
||||
const navigate = jest.fn();
|
||||
|
||||
const cipherDomain = {
|
||||
type: CipherType.Login,
|
||||
name: "Test Login",
|
||||
};
|
||||
|
||||
const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain);
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherServiceGet.mockClear();
|
||||
navigate.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AttachmentsV2Component],
|
||||
providers: [
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: {
|
||||
get: cipherServiceGet,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(AttachmentsV2Component, {
|
||||
remove: {
|
||||
imports: [PopupHeaderComponent, PopupFooterComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockPopupHeaderComponent, MockPopupFooterComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(AttachmentsV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
cipherAttachment = fixture.debugElement.query(
|
||||
By.directive(CipherAttachmentsComponent),
|
||||
).componentInstance;
|
||||
});
|
||||
|
||||
it("sets `cipherId` from query params", () => {
|
||||
expect(component.cipherId).toBe("5555-444-3333");
|
||||
});
|
||||
|
||||
it("passes the submit button to the cipher attachments component", () => {
|
||||
const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1]
|
||||
.componentInstance;
|
||||
|
||||
expect(cipherAttachment.submitBtn).toEqual(submitBtn);
|
||||
});
|
||||
|
||||
it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => {
|
||||
cipherAttachment.onUploadSuccess.emit();
|
||||
|
||||
tick();
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/edit-cipher"], {
|
||||
queryParams: { cipherId: "5555-444-3333", type: CipherType.Login },
|
||||
replaceUrl: true,
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { first } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
|
||||
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
|
||||
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
import { CipherAttachmentsComponent } from "./cipher-attachments/cipher-attachments.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-attachments-v2",
|
||||
templateUrl: "./attachments-v2.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonModule,
|
||||
JslibModule,
|
||||
CipherAttachmentsComponent,
|
||||
PopupPageComponent,
|
||||
PopupHeaderComponent,
|
||||
PopupFooterComponent,
|
||||
PopOutComponent,
|
||||
],
|
||||
})
|
||||
export class AttachmentsV2Component {
|
||||
/** The `id` tied to the underlying HTMLFormElement */
|
||||
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
|
||||
|
||||
/** Id of the cipher */
|
||||
cipherId: CipherId;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private cipherService: CipherService,
|
||||
route: ActivatedRoute,
|
||||
) {
|
||||
route.queryParams.pipe(takeUntilDestroyed(), first()).subscribe(({ cipherId }) => {
|
||||
this.cipherId = cipherId;
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate the user back to the edit screen after uploading an attachment */
|
||||
async navigateToEditScreen() {
|
||||
const cipherDomain = await this.cipherService.get(this.cipherId);
|
||||
|
||||
void this.router.navigate(["/edit-cipher"], {
|
||||
queryParams: { cipherId: this.cipherId, type: cipherDomain.type },
|
||||
// "replaceUrl" so the /attachments route is not in the history, thus when a back button
|
||||
// is clicked, the user is taken to the view screen instead of the attachments screen
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
|
||||
|
||||
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none">
|
||||
<li *ngFor="let attachment of cipher.attachments">
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
{{ attachment.fileName }}
|
||||
<span slot="secondary">{{ attachment.sizeName }}</span>
|
||||
</bit-item-content>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<app-download-attachment
|
||||
[cipher]="cipher"
|
||||
[attachment]="attachment"
|
||||
></app-download-attachment>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[cipherId]="cipher.id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
|
||||
<bit-card>
|
||||
<label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5">
|
||||
{{ "addAttachment" | i18n }}
|
||||
</label>
|
||||
<div class="tw-relative">
|
||||
<!-- Input elements are notoriously difficult to style, --->
|
||||
<!-- The native `<input>` will be used for screen readers -->
|
||||
<!-- Visual & keyboard users will interact with the styled button element -->
|
||||
<input
|
||||
#fileInput
|
||||
class="tw-sr-only"
|
||||
type="file"
|
||||
id="file"
|
||||
name="file"
|
||||
aria-describedby="fileHelp"
|
||||
tabindex="-1"
|
||||
required
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
<div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true">
|
||||
<button bitButton buttonType="secondary" type="button" (click)="fileInput.click()">
|
||||
{{ "chooseFile" | i18n }}
|
||||
</button>
|
||||
<p bitTypography="body2" class="tw-text-muted tw-mb-0">
|
||||
{{
|
||||
this.attachmentForm.controls.file?.value
|
||||
? this.attachmentForm.controls.file.value.name
|
||||
: ("noFileChosen" | i18n)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0">
|
||||
{{ "maxFileSizeSansPunctuation" | i18n }}
|
||||
</p>
|
||||
</bit-card>
|
||||
</form>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { ButtonComponent, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { CipherAttachmentsComponent } from "./cipher-attachments.component";
|
||||
import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component";
|
||||
import { DownloadAttachmentComponent } from "./download-attachment/download-attachment.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-download-attachment",
|
||||
template: "",
|
||||
})
|
||||
class MockDownloadAttachmentComponent {
|
||||
@Input() attachment: AttachmentView;
|
||||
@Input() cipher: CipherView;
|
||||
}
|
||||
|
||||
describe("CipherAttachmentsComponent", () => {
|
||||
let component: CipherAttachmentsComponent;
|
||||
let fixture: ComponentFixture<CipherAttachmentsComponent>;
|
||||
const showToast = jest.fn();
|
||||
const cipherView = {
|
||||
id: "5555-444-3333",
|
||||
type: CipherType.Login,
|
||||
name: "Test Login",
|
||||
login: {
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
} as CipherView;
|
||||
|
||||
const cipherDomain = {
|
||||
decrypt: () => cipherView,
|
||||
};
|
||||
|
||||
const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain);
|
||||
const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain);
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherServiceGet.mockClear();
|
||||
showToast.mockClear();
|
||||
saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CipherAttachmentsComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: {
|
||||
get: cipherServiceGet,
|
||||
saveAttachmentWithServer,
|
||||
getKeyForCipherKeyDecryption: () => Promise.resolve(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
showToast,
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
],
|
||||
})
|
||||
.overrideComponent(CipherAttachmentsComponent, {
|
||||
remove: {
|
||||
imports: [DownloadAttachmentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockDownloadAttachmentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.cipherId = "5555-444-3333" as CipherId;
|
||||
component.submitBtn = {} as ButtonComponent;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("fetches cipherView using `cipherId`", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333");
|
||||
expect(component.cipher).toEqual(cipherView);
|
||||
});
|
||||
|
||||
describe("bitSubmit", () => {
|
||||
beforeEach(() => {
|
||||
component.submitBtn.disabled = undefined;
|
||||
component.submitBtn.loading = undefined;
|
||||
});
|
||||
|
||||
it("updates sets initial state of the submit button", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.submitBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets submitBtn loading state", () => {
|
||||
component.bitSubmit.loading = true;
|
||||
|
||||
expect(component.submitBtn.loading).toBe(true);
|
||||
|
||||
component.bitSubmit.loading = false;
|
||||
|
||||
expect(component.submitBtn.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("sets submitBtn disabled state", () => {
|
||||
component.bitSubmit.disabled = true;
|
||||
|
||||
expect(component.submitBtn.disabled).toBe(true);
|
||||
|
||||
component.bitSubmit.disabled = false;
|
||||
|
||||
expect(component.submitBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachmentForm", () => {
|
||||
let file: File;
|
||||
|
||||
beforeEach(() => {
|
||||
component.submitBtn.disabled = undefined;
|
||||
file = new File([""], "attachment.txt", { type: "text/plain" });
|
||||
|
||||
const inputElement = fixture.debugElement.query(By.css("input[type=file]"));
|
||||
|
||||
// Set the file value of the input element
|
||||
Object.defineProperty(inputElement.nativeElement, "files", {
|
||||
value: [file],
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// Trigger change event, for event listeners
|
||||
inputElement.nativeElement.dispatchEvent(new InputEvent("change"));
|
||||
});
|
||||
|
||||
it("sets value of `file` control when input changes", () => {
|
||||
expect(component.attachmentForm.controls.file.value.name).toEqual(file.name);
|
||||
});
|
||||
|
||||
it("updates disabled state of submit button", () => {
|
||||
expect(component.submitBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
describe("error", () => {
|
||||
it("shows error toast if no file is selected", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "errorOccurred",
|
||||
message: "selectFile",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast if file size is greater than 500MB", async () => {
|
||||
component.attachmentForm.controls.file.setValue({
|
||||
size: 524288001,
|
||||
} as File);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "errorOccurred",
|
||||
message: "maxFileSize",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("success", () => {
|
||||
const file = { size: 524287999 } as File;
|
||||
|
||||
beforeEach(() => {
|
||||
component.attachmentForm.controls.file.setValue(file);
|
||||
});
|
||||
|
||||
it("calls `saveAttachmentWithServer`", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file);
|
||||
});
|
||||
|
||||
it("resets form and input values", async () => {
|
||||
await component.submit();
|
||||
|
||||
const fileInput = fixture.debugElement.query(By.css("input[type=file]"));
|
||||
|
||||
expect(fileInput.nativeElement.value).toEqual("");
|
||||
expect(component.attachmentForm.controls.file.value).toEqual(null);
|
||||
});
|
||||
|
||||
it("shows success toast", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: "attachmentSaved",
|
||||
});
|
||||
});
|
||||
|
||||
it('emits "onUploadSuccess"', async () => {
|
||||
const emitSpy = jest.spyOn(component.onUploadSuccess, "emit");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAttachment", () => {
|
||||
const attachment = { id: "1234-5678" } as AttachmentView;
|
||||
|
||||
beforeEach(() => {
|
||||
component.cipher.attachments = [attachment];
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("removes attachment from cipher", () => {
|
||||
const deleteAttachmentComponent = fixture.debugElement.query(
|
||||
By.directive(DeleteAttachmentComponent),
|
||||
).componentInstance as DeleteAttachmentComponent;
|
||||
|
||||
deleteAttachmentComponent.onDeletionSuccess.emit();
|
||||
|
||||
expect(component.cipher.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
BitSubmitDirective,
|
||||
ButtonComponent,
|
||||
ButtonModule,
|
||||
CardComponent,
|
||||
ItemModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component";
|
||||
import { DownloadAttachmentComponent } from "./download-attachment/download-attachment.component";
|
||||
|
||||
type CipherAttachmentForm = FormGroup<{
|
||||
file: FormControl<File | null>;
|
||||
}>;
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-cipher-attachments",
|
||||
templateUrl: "./cipher-attachments.component.html",
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
ItemModule,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
TypographyModule,
|
||||
CardComponent,
|
||||
DeleteAttachmentComponent,
|
||||
DownloadAttachmentComponent,
|
||||
],
|
||||
})
|
||||
export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
/** `id` associated with the form element */
|
||||
static attachmentFormID = "attachmentForm";
|
||||
|
||||
/** Reference to the file HTMLInputElement */
|
||||
@ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
/** Reference to the BitSubmitDirective */
|
||||
@ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective;
|
||||
|
||||
/** The `id` of the cipher in context */
|
||||
@Input({ required: true }) cipherId: CipherId;
|
||||
|
||||
/** An optional submit button, whose loading/disabled state will be tied to the form state. */
|
||||
@Input() submitBtn?: ButtonComponent;
|
||||
|
||||
/** Emits after a file has been successfully uploaded */
|
||||
@Output() onUploadSuccess = new EventEmitter<void>();
|
||||
|
||||
cipher: CipherView;
|
||||
|
||||
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
|
||||
file: new FormControl<File>(null, [Validators.required]),
|
||||
});
|
||||
|
||||
private cipherDomain: Cipher;
|
||||
private destroy$ = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private i18nService: I18nService,
|
||||
private formBuilder: FormBuilder,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
) {
|
||||
this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => {
|
||||
if (!this.submitBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.disabled = status !== "VALID";
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.cipherDomain = await this.cipherService.get(this.cipherId);
|
||||
this.cipher = await this.cipherDomain.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain),
|
||||
);
|
||||
|
||||
// Update the initial state of the submit button
|
||||
if (this.submitBtn) {
|
||||
this.submitBtn.disabled = !this.attachmentForm.valid;
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((loading) => {
|
||||
if (!this.submitBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.loading = loading;
|
||||
});
|
||||
|
||||
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => {
|
||||
if (!this.submitBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
/** Reference the `id` via the static property */
|
||||
get attachmentFormId(): string {
|
||||
return CipherAttachmentsComponent.attachmentFormID;
|
||||
}
|
||||
|
||||
/** Updates the form value when a file is selected */
|
||||
onFileChange(event: Event): void {
|
||||
const fileInputEl = event.target as HTMLInputElement;
|
||||
|
||||
if (fileInputEl.files && fileInputEl.files.length > 0) {
|
||||
this.attachmentForm.controls.file.setValue(fileInputEl.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the attachments to the cipher */
|
||||
submit = async () => {
|
||||
const file = this.attachmentForm.value.file;
|
||||
if (file === null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("selectFile"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 524288000) {
|
||||
// 500 MB
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("maxFileSize"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.cipherDomain = await this.cipherService.saveAttachmentWithServer(
|
||||
this.cipherDomain,
|
||||
file,
|
||||
);
|
||||
|
||||
// re-decrypt the cipher to update the attachments
|
||||
this.cipher = await this.cipherDomain.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain),
|
||||
);
|
||||
|
||||
// Reset reactive form and input element
|
||||
this.fileInput.nativeElement.value = "";
|
||||
this.attachmentForm.controls.file.setValue(null);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("attachmentSaved"),
|
||||
});
|
||||
|
||||
this.onUploadSuccess.emit();
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Removes the attachment from the cipher */
|
||||
removeAttachment(attachment: AttachmentView) {
|
||||
const index = this.cipher.attachments.indexOf(attachment);
|
||||
|
||||
if (index > -1) {
|
||||
this.cipher.attachments.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<button
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
size="small"
|
||||
type="button"
|
||||
class="tw-border-none"
|
||||
[appA11yTitle]="'deleteAttachmentName' | i18n: attachment.fileName"
|
||||
[bitAction]="delete"
|
||||
></button>
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { DeleteAttachmentComponent } from "./delete-attachment.component";
|
||||
|
||||
describe("DeleteAttachmentComponent", () => {
|
||||
let component: DeleteAttachmentComponent;
|
||||
let fixture: ComponentFixture<DeleteAttachmentComponent>;
|
||||
const showToast = jest.fn();
|
||||
const attachment = {
|
||||
id: "222-3333-4444",
|
||||
url: "attachment-url",
|
||||
fileName: "attachment-filename",
|
||||
size: "1234",
|
||||
} as AttachmentView;
|
||||
|
||||
const deleteAttachmentWithServer = jest.fn().mockResolvedValue(null);
|
||||
const openSimpleDialog = jest.fn().mockResolvedValue(true);
|
||||
|
||||
beforeEach(async () => {
|
||||
deleteAttachmentWithServer.mockClear();
|
||||
showToast.mockClear();
|
||||
openSimpleDialog.mockClear().mockResolvedValue(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DeleteAttachmentComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: { deleteAttachmentWithServer },
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showToast },
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
],
|
||||
})
|
||||
.overrideProvider(DialogService, {
|
||||
useValue: {
|
||||
openSimpleDialog,
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DeleteAttachmentComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.cipherId = "5555-444-3333";
|
||||
component.attachment = attachment;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName");
|
||||
});
|
||||
|
||||
it("does not delete when the user cancels the dialog", async () => {
|
||||
openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.delete();
|
||||
|
||||
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "deleteAttachment" },
|
||||
content: { key: "permanentlyDeleteAttachmentConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
expect(deleteAttachmentWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the attachment", async () => {
|
||||
await component.delete();
|
||||
|
||||
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "deleteAttachment" },
|
||||
content: { key: "permanentlyDeleteAttachmentConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
// Called with cipher id and attachment id
|
||||
expect(deleteAttachmentWithServer).toHaveBeenCalledWith("5555-444-3333", "222-3333-4444");
|
||||
});
|
||||
|
||||
it("shows toast message on successful deletion", async () => {
|
||||
await component.delete();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: "deletedAttachment",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-delete-attachment",
|
||||
templateUrl: "./delete-attachment.component.html",
|
||||
imports: [AsyncActionsModule, CommonModule, JslibModule, ButtonModule, IconButtonModule],
|
||||
})
|
||||
export class DeleteAttachmentComponent {
|
||||
/** Id of the cipher associated with the attachment */
|
||||
@Input({ required: true }) cipherId: string;
|
||||
|
||||
/** The attachment that is can be deleted */
|
||||
@Input({ required: true }) attachment: AttachmentView;
|
||||
|
||||
/** Emits when the attachment is successfully deleted */
|
||||
@Output() onDeletionSuccess = new EventEmitter<void>();
|
||||
|
||||
constructor(
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
delete = async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteAttachment" },
|
||||
content: { key: "permanentlyDeleteAttachmentConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cipherService.deleteAttachmentWithServer(this.cipherId, this.attachment.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("deletedAttachment"),
|
||||
});
|
||||
|
||||
this.onDeletionSuccess.emit();
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<button
|
||||
[bitAction]="download"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName"
|
||||
></button>
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { ToastService } from "../../../../../../../../../../libs/components/src/toast";
|
||||
|
||||
import { DownloadAttachmentComponent } from "./download-attachment.component";
|
||||
|
||||
class MockRequest {
|
||||
constructor(public url: string) {}
|
||||
}
|
||||
|
||||
describe("DownloadAttachmentComponent", () => {
|
||||
let component: DownloadAttachmentComponent;
|
||||
let fixture: ComponentFixture<DownloadAttachmentComponent>;
|
||||
const activeUserId$ = new BehaviorSubject("888-333-222-222");
|
||||
const showToast = jest.fn();
|
||||
const getAttachmentData = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ url: "https://www.downloadattachement.com" });
|
||||
const download = jest.fn();
|
||||
|
||||
const attachment = {
|
||||
id: "222-3333-4444",
|
||||
url: "https://www.attachment.com",
|
||||
fileName: "attachment-filename",
|
||||
size: "1234",
|
||||
} as AttachmentView;
|
||||
|
||||
const cipherView = {
|
||||
id: "5555-444-3333",
|
||||
type: CipherType.Login,
|
||||
name: "Test Login",
|
||||
login: {
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
} as CipherView;
|
||||
|
||||
beforeEach(async () => {
|
||||
showToast.mockClear();
|
||||
getAttachmentData.mockClear();
|
||||
download.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DownloadAttachmentComponent],
|
||||
providers: [
|
||||
{ provide: EncryptService, useValue: mock<EncryptService>() },
|
||||
{ provide: CryptoService, useValue: mock<CryptoService>() },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: StateProvider, useValue: { activeUserId$ } },
|
||||
{ provide: ToastService, useValue: { showToast } },
|
||||
{ provide: ApiService, useValue: { getAttachmentData } },
|
||||
{ provide: FileDownloadService, useValue: { download } },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DownloadAttachmentComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.attachment = attachment;
|
||||
component.cipher = cipherView;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName");
|
||||
});
|
||||
|
||||
describe("download attachment", () => {
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.fn().mockResolvedValue({});
|
||||
global.fetch = fetchMock;
|
||||
// Request is not defined in the Jest runtime
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Request = MockRequest as any;
|
||||
});
|
||||
|
||||
it("uses the attachment url when available when getAttachmentData returns a 404", async () => {
|
||||
getAttachmentData.mockRejectedValue(new ErrorResponse({}, 404));
|
||||
|
||||
await component.download();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith({ url: attachment.url });
|
||||
});
|
||||
|
||||
it("calls file download service with the attachment url", async () => {
|
||||
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue({});
|
||||
|
||||
await component.download();
|
||||
|
||||
expect(download).toHaveBeenCalledWith({ blobData: undefined, fileName: attachment.fileName });
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
it("shows an error toast when fetch fails", async () => {
|
||||
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
|
||||
fetchMock.mockResolvedValue({ status: 500 });
|
||||
|
||||
await component.download();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "errorOccurred",
|
||||
title: null,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error toast when EncArrayBuffer fails", async () => {
|
||||
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
EncArrayBuffer.fromResponse = jest.fn().mockRejectedValue({});
|
||||
|
||||
await component.download();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "errorOccurred",
|
||||
title: null,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NEVER, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-download-attachment",
|
||||
templateUrl: "./download-attachment.component.html",
|
||||
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
|
||||
})
|
||||
export class DownloadAttachmentComponent {
|
||||
/** Attachment to download */
|
||||
@Input({ required: true }) attachment: AttachmentView;
|
||||
|
||||
/** The cipher associated with the attachment */
|
||||
@Input({ required: true }) cipher: CipherView;
|
||||
|
||||
/** The organization key if the cipher is associated with one */
|
||||
private orgKey: OrgKey | null = null;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private apiService: ApiService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private toastService: ToastService,
|
||||
private encryptService: EncryptService,
|
||||
private stateProvider: StateProvider,
|
||||
private cryptoService: CryptoService,
|
||||
) {
|
||||
this.stateProvider.activeUserId$
|
||||
.pipe(
|
||||
switchMap((userId) => (userId !== null ? this.cryptoService.orgKeys$(userId) : NEVER)),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((data: Record<OrganizationId, OrgKey> | null) => {
|
||||
if (data) {
|
||||
this.orgKey = data[this.cipher.organizationId as OrganizationId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Download the attachment */
|
||||
download = async () => {
|
||||
let url: string;
|
||||
|
||||
try {
|
||||
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
|
||||
this.cipher.id,
|
||||
this.attachment.id,
|
||||
);
|
||||
url = attachmentDownloadResponse.url;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
|
||||
url = this.attachment.url;
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(new Request(url, { cache: "no-store" }));
|
||||
if (response.status !== 200) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const encBuf = await EncArrayBuffer.fromResponse(response);
|
||||
const key = this.attachment.key != null ? this.attachment.key : this.orgKey;
|
||||
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
|
||||
this.fileDownloadService.download({
|
||||
fileName: this.attachment.fileName,
|
||||
blobData: decBuf,
|
||||
});
|
||||
} catch (e) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<bit-item>
|
||||
<button bit-item-content type="button" (click)="openAttachments()">
|
||||
<p class="tw-m-0">
|
||||
{{ "attachments" | i18n }}
|
||||
<span *ngIf="!canAccessAttachments" bitBadge variant="success" class="tw-ml-2">
|
||||
{{ "premium" | i18n }}
|
||||
</span>
|
||||
</p>
|
||||
<ng-container slot="end">
|
||||
<i class="bwi bwi-popout" aria-hidden="true" *ngIf="openAttachmentsInPopout"></i>
|
||||
<i class="bwi bwi-angle-right" aria-hidden="true" *ngIf="!openAttachmentsInPopout"></i>
|
||||
</ng-container>
|
||||
</button>
|
||||
</bit-item>
|
||||
@@ -0,0 +1,166 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils";
|
||||
|
||||
import { OpenAttachmentsComponent } from "./open-attachments.component";
|
||||
|
||||
describe("OpenAttachmentsComponent", () => {
|
||||
let component: OpenAttachmentsComponent;
|
||||
let fixture: ComponentFixture<OpenAttachmentsComponent>;
|
||||
let router: Router;
|
||||
const showToast = jest.fn();
|
||||
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(true);
|
||||
const openCurrentPagePopout = jest
|
||||
.spyOn(BrowserPopupUtils, "openCurrentPagePopout")
|
||||
.mockResolvedValue(null);
|
||||
const cipherView = {
|
||||
id: "5555-444-3333",
|
||||
type: CipherType.Login,
|
||||
name: "Test Login",
|
||||
login: {
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
} as CipherView;
|
||||
|
||||
const cipherDomain = {
|
||||
decrypt: () => cipherView,
|
||||
};
|
||||
|
||||
const org = {
|
||||
name: "Test Org",
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
} as Organization;
|
||||
|
||||
const getCipher = jest.fn().mockResolvedValue(cipherDomain);
|
||||
const getOrganization = jest.fn().mockResolvedValue(org);
|
||||
|
||||
beforeEach(async () => {
|
||||
openCurrentPagePopout.mockClear();
|
||||
getCipher.mockClear();
|
||||
showToast.mockClear();
|
||||
getOrganization.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [OpenAttachmentsComponent, RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$ } },
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: {
|
||||
get: getCipher,
|
||||
getKeyForCipherKeyDecryption: () => Promise.resolve(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showToast },
|
||||
},
|
||||
{
|
||||
provide: OrganizationService,
|
||||
useValue: { get: getOrganization },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OpenAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.cipherId = "5555-444-3333" as CipherId;
|
||||
router = TestBed.inject(Router);
|
||||
jest.spyOn(router, "navigate").mockResolvedValue(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("opens attachments in new popout", async () => {
|
||||
component.openAttachmentsInPopout = true;
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect(openCurrentPagePopout).toHaveBeenCalledWith(
|
||||
window,
|
||||
"http:/localhost//attachments?cipherId=5555-444-3333",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens attachments in same window", async () => {
|
||||
component.openAttachmentsInPopout = false;
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(openCurrentPagePopout).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/attachments"], {
|
||||
queryParams: { cipherId: "5555-444-3333" },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the user to the premium page when they cannot access premium features", async () => {
|
||||
hasPremiumFromAnySource$.next(false);
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/premium"]);
|
||||
});
|
||||
|
||||
describe("Free Orgs", () => {
|
||||
beforeEach(() => {
|
||||
component.cipherIsAPartOfFreeOrg = undefined;
|
||||
});
|
||||
|
||||
it("sets `cipherIsAPartOfFreeOrg` to false when the cipher is not a part of an organization", async () => {
|
||||
cipherView.organizationId = null;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.cipherIsAPartOfFreeOrg).toBe(false);
|
||||
});
|
||||
|
||||
it("sets `cipherIsAPartOfFreeOrg` to true when the cipher is a part of a free organization", async () => {
|
||||
cipherView.organizationId = "888-333-333";
|
||||
org.productTierType = ProductTierType.Free;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.cipherIsAPartOfFreeOrg).toBe(true);
|
||||
});
|
||||
|
||||
it("sets `cipherIsAPartOfFreeOrg` to false when the organization is not free", async () => {
|
||||
cipherView.organizationId = "888-333-333";
|
||||
org.productTierType = ProductTierType.Families;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.cipherIsAPartOfFreeOrg).toBe(false);
|
||||
});
|
||||
|
||||
it("shows toast when the cipher is a part of a free org", async () => {
|
||||
component.canAccessAttachments = true;
|
||||
component.cipherIsAPartOfFreeOrg = true;
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: "freeOrgsCannotUseAttachments",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
CardComponent,
|
||||
ItemModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "app-open-attachments",
|
||||
templateUrl: "./open-attachments.component.html",
|
||||
imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule, CardComponent],
|
||||
})
|
||||
export class OpenAttachmentsComponent implements OnInit {
|
||||
/** Cipher `id` */
|
||||
@Input({ required: true }) cipherId: CipherId;
|
||||
|
||||
/** True when the attachments window should be opened in a popout */
|
||||
openAttachmentsInPopout = BrowserPopupUtils.inPopup(window);
|
||||
|
||||
/** True when the user has access to premium or h */
|
||||
canAccessAttachments: boolean;
|
||||
|
||||
/** True when the cipher is a part of a free organization */
|
||||
cipherIsAPartOfFreeOrg: boolean;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private cipherService: CipherService,
|
||||
private organizationService: OrganizationService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((canAccessPremium) => {
|
||||
this.canAccessAttachments = canAccessPremium;
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const cipherDomain = await this.cipherService.get(this.cipherId);
|
||||
const cipher = await cipherDomain.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain),
|
||||
);
|
||||
|
||||
if (!cipher.organizationId) {
|
||||
this.cipherIsAPartOfFreeOrg = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const org = await this.organizationService.get(cipher.organizationId);
|
||||
|
||||
this.cipherIsAPartOfFreeOrg = org.productTierType === ProductTierType.Free;
|
||||
}
|
||||
|
||||
/** Routes the user to the attachments screen, if available */
|
||||
async openAttachments() {
|
||||
if (!this.canAccessAttachments) {
|
||||
await this.router.navigate(["/premium"]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cipherIsAPartOfFreeOrg) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("freeOrgsCannotUseAttachments"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.openAttachmentsInPopout) {
|
||||
const destinationUrl = this.router
|
||||
.createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipherId } })
|
||||
.toString();
|
||||
|
||||
const currentBaseUrl = window.location.href.replace(this.router.url, "");
|
||||
|
||||
await BrowserPopupUtils.openCurrentPagePopout(window, currentBaseUrl + destinationUrl);
|
||||
} else {
|
||||
await this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipherId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user