1
0
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:
Nick Krantz
2024-06-27 10:50:45 -05:00
committed by GitHub
parent 98c6cc4a7e
commit 5ce4e8f4e5
19 changed files with 1506 additions and 4 deletions

View File

@@ -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"
}

View File

@@ -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,

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { DeleteAttachmentComponent } from "./delete-attachment.component";
describe("DeleteAttachmentComponent", () => {
let component: DeleteAttachmentComponent;
let fixture: ComponentFixture<DeleteAttachmentComponent>;
const showToast = jest.fn();
const attachment = {
id: "222-3333-4444",
url: "attachment-url",
fileName: "attachment-filename",
size: "1234",
} as AttachmentView;
const deleteAttachmentWithServer = jest.fn().mockResolvedValue(null);
const openSimpleDialog = jest.fn().mockResolvedValue(true);
beforeEach(async () => {
deleteAttachmentWithServer.mockClear();
showToast.mockClear();
openSimpleDialog.mockClear().mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [DeleteAttachmentComponent],
providers: [
{
provide: CipherService,
useValue: { deleteAttachmentWithServer },
},
{
provide: ToastService,
useValue: { showToast },
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: LogService, useValue: mock<LogService>() },
],
})
.overrideProvider(DialogService, {
useValue: {
openSimpleDialog,
},
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DeleteAttachmentComponent);
component = fixture.componentInstance;
component.cipherId = "5555-444-3333";
component.attachment = attachment;
fixture.detectChanges();
});
it("renders delete button", () => {
const deleteButton = fixture.debugElement.query(By.css("button"));
expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName");
});
it("does not delete when the user cancels the dialog", async () => {
openSimpleDialog.mockResolvedValue(false);
await component.delete();
expect(openSimpleDialog).toHaveBeenCalledWith({
title: { key: "deleteAttachment" },
content: { key: "permanentlyDeleteAttachmentConfirmation" },
type: "warning",
});
expect(deleteAttachmentWithServer).not.toHaveBeenCalled();
});
it("deletes the attachment", async () => {
await component.delete();
expect(openSimpleDialog).toHaveBeenCalledWith({
title: { key: "deleteAttachment" },
content: { key: "permanentlyDeleteAttachmentConfirmation" },
type: "warning",
});
// Called with cipher id and attachment id
expect(deleteAttachmentWithServer).toHaveBeenCalledWith("5555-444-3333", "222-3333-4444");
});
it("shows toast message on successful deletion", async () => {
await component.delete();
expect(showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: "deletedAttachment",
});
});
});

View File

@@ -0,0 +1,66 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import {
AsyncActionsModule,
ButtonModule,
DialogService,
IconButtonModule,
ToastService,
} from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-delete-attachment",
templateUrl: "./delete-attachment.component.html",
imports: [AsyncActionsModule, CommonModule, JslibModule, ButtonModule, IconButtonModule],
})
export class DeleteAttachmentComponent {
/** Id of the cipher associated with the attachment */
@Input({ required: true }) cipherId: string;
/** The attachment that is can be deleted */
@Input({ required: true }) attachment: AttachmentView;
/** Emits when the attachment is successfully deleted */
@Output() onDeletionSuccess = new EventEmitter<void>();
constructor(
private toastService: ToastService,
private i18nService: I18nService,
private cipherService: CipherService,
private logService: LogService,
private dialogService: DialogService,
) {}
delete = async () => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteAttachment" },
content: { key: "permanentlyDeleteAttachmentConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.cipherService.deleteAttachmentWithServer(this.cipherId, this.attachment.id);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedAttachment"),
});
this.onDeletionSuccess.emit();
} catch (e) {
this.logService.error(e);
}
};
}

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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