mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +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": {
|
"contactYourOrgAdmin": {
|
||||||
"message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance."
|
"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": {
|
"filters": {
|
||||||
"message": "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 { VaultV2Component } from "../vault/popup/components/vault/vault-v2.component";
|
||||||
import { ViewComponent } from "../vault/popup/components/vault/view.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 { 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 { AppearanceComponent } from "../vault/popup/settings/appearance.component";
|
||||||
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
|
import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component";
|
||||||
import { FoldersComponent } from "../vault/popup/settings/folders.component";
|
import { FoldersComponent } from "../vault/popup/settings/folders.component";
|
||||||
@@ -230,12 +231,11 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
data: { state: "collections" },
|
data: { state: "collections" },
|
||||||
},
|
},
|
||||||
{
|
...extensionRefreshSwap(AttachmentsComponent, AttachmentsV2Component, {
|
||||||
path: "attachments",
|
path: "attachments",
|
||||||
component: AttachmentsComponent,
|
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
data: { state: "attachments" },
|
data: { state: "attachments" },
|
||||||
},
|
}),
|
||||||
{
|
{
|
||||||
path: "generator",
|
path: "generator",
|
||||||
component: GeneratorComponent,
|
component: GeneratorComponent,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<popup-page>
|
<popup-page>
|
||||||
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
|
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
|
||||||
|
|
||||||
|
<app-open-attachments *ngIf="isEdit" [cipherId]="cipherId"></app-open-attachments>
|
||||||
|
|
||||||
<popup-footer slot="footer">
|
<popup-footer slot="footer">
|
||||||
<button bitButton type="button" buttonType="primary">
|
<button bitButton type="button" buttonType="primary">
|
||||||
{{ "save" | i18n }}
|
{{ "save" | i18n }}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { ActivatedRoute } from "@angular/router";
|
|||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { CipherId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { SearchModule, ButtonModule } from "@bitwarden/components";
|
import { SearchModule, ButtonModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
|
||||||
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
|
||||||
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
|
||||||
|
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-add-edit-v2",
|
selector: "app-add-edit-v2",
|
||||||
@@ -23,6 +25,7 @@ import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-p
|
|||||||
JslibModule,
|
JslibModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
|
OpenAttachmentsComponent,
|
||||||
PopupPageComponent,
|
PopupPageComponent,
|
||||||
PopupHeaderComponent,
|
PopupHeaderComponent,
|
||||||
PopupFooterComponent,
|
PopupFooterComponent,
|
||||||
@@ -30,6 +33,8 @@ import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-p
|
|||||||
})
|
})
|
||||||
export class AddEditV2Component {
|
export class AddEditV2Component {
|
||||||
headerText: string;
|
headerText: string;
|
||||||
|
cipherId: CipherId;
|
||||||
|
isEdit: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -40,9 +45,11 @@ export class AddEditV2Component {
|
|||||||
|
|
||||||
subscribeToParams(): void {
|
subscribeToParams(): void {
|
||||||
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
|
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
const isNew = params.isNew.toLowerCase() === "true";
|
const isNew = params.isNew?.toLowerCase() === "true";
|
||||||
const cipherType = parseInt(params.type);
|
const cipherType = parseInt(params.type);
|
||||||
|
|
||||||
|
this.isEdit = !isNew;
|
||||||
|
this.cipherId = params.cipherId;
|
||||||
this.headerText = this.setHeader(isNew, cipherType);
|
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