mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 11:54:02 +00:00
Merge branch 'main' into PM-25685
This commit is contained in:
44
libs/vault/src/abstractions/vault-filter.service.ts
Normal file
44
libs/vault/src/abstractions/vault-filter.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionAdminView,
|
||||
CollectionView,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import {
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "../models/vault-filter.type";
|
||||
|
||||
export abstract class VaultFilterService {
|
||||
collapsedFilterNodes$: Observable<Set<string>>;
|
||||
filteredFolders$: Observable<FolderView[]>;
|
||||
filteredCollections$: Observable<CollectionView[]>;
|
||||
organizationTree$: Observable<TreeNode<OrganizationFilter>>;
|
||||
folderTree$: Observable<TreeNode<FolderFilter>>;
|
||||
collectionTree$: Observable<TreeNode<CollectionFilter>>;
|
||||
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
|
||||
abstract getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
|
||||
abstract setCollapsedFilterNodes: (
|
||||
collapsedFilterNodes: Set<string>,
|
||||
userId: UserId,
|
||||
) => Promise<void>;
|
||||
abstract expandOrgFilter: (userId: UserId) => Promise<void>;
|
||||
abstract getOrganizationFilter: () => Observable<Organization>;
|
||||
abstract setOrganizationFilter: (organization: Organization) => void;
|
||||
abstract buildTypeTree: (
|
||||
head: CipherTypeFilter,
|
||||
array: CipherTypeFilter[],
|
||||
) => Observable<TreeNode<CipherTypeFilter>>;
|
||||
// TODO: Remove this from org vault when collection admin service adopts state management
|
||||
abstract reloadCollections?: (collections: CollectionAdminView[]) => void;
|
||||
abstract clearOrganizationFilter: () => void;
|
||||
}
|
||||
64
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
64
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export type UserMigrationInfo =
|
||||
| {
|
||||
/**
|
||||
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
|
||||
* organizational policy change. (Enforce organization data ownership policy enabled)
|
||||
*/
|
||||
requiresMigration: false;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
|
||||
* organizational policy change. (Enforce organization data ownership policy enabled)
|
||||
*/
|
||||
requiresMigration: true;
|
||||
|
||||
/**
|
||||
* The organization that is enforcing data ownership policies for the given user.
|
||||
*/
|
||||
enforcingOrganization: Organization;
|
||||
|
||||
/**
|
||||
* The default collection ID for the user in the enforcing organization, if available.
|
||||
*/
|
||||
defaultCollectionId?: CollectionId;
|
||||
};
|
||||
|
||||
export abstract class VaultItemsTransferService {
|
||||
/**
|
||||
* Indicates whether a vault items transfer is currently in progress.
|
||||
*/
|
||||
abstract transferInProgress$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Gets information about whether the given user requires migration of their vault items
|
||||
* from My Vault to a My Items collection, and whether they are capable of performing that migration.
|
||||
* @param userId
|
||||
*/
|
||||
abstract userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo>;
|
||||
|
||||
/**
|
||||
* Enforces organization data ownership for the given user by transferring vault items.
|
||||
* Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding.
|
||||
*
|
||||
* Rejecting the transfer will result in the user being revoked from the organization.
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
abstract enforceOrganizationDataOwnership(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Begins transfer of vault items from My Vault to the specified default collection for the given user.
|
||||
*/
|
||||
abstract transferPersonalItems(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
defaultCollectionId: CollectionId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controls, Meta, Primary } from "@storybook/addon-docs";
|
||||
import { Controls, Meta, Primary } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./cipher-form.stories";
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { importProvidersFrom, signal } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import {
|
||||
applicationConfig,
|
||||
componentWrapperDecorator,
|
||||
@@ -11,15 +10,14 @@ import {
|
||||
StoryObj,
|
||||
} from "@storybook/angular";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { action } from "storybook/actions";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
<h2 class="tw-sr-only" id="attachments">{{ "attachments" | i18n }}</h2>
|
||||
|
||||
<ul *ngIf="cipher?.attachments" aria-labelledby="attachments" class="tw-list-none tw-pl-0">
|
||||
<li *ngFor="let attachment of cipher.attachments">
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
|
||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||
</bit-item-content>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<app-download-attachment
|
||||
[admin]="admin && organization?.canEditAllCiphers"
|
||||
[cipher]="cipher"
|
||||
[attachment]="attachment"
|
||||
></app-download-attachment>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[admin]="admin && organization?.canEditAllCiphers"
|
||||
[cipherId]="cipher.id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</li>
|
||||
</ul>
|
||||
@if (cipher()?.attachments; as attachments) {
|
||||
<ul aria-labelledby="attachments" class="tw-list-none tw-pl-0">
|
||||
@for (attachment of attachments; track attachment.id) {
|
||||
<li>
|
||||
<bit-item>
|
||||
<bit-item-content>
|
||||
<span data-testid="file-name" [title]="attachment.fileName">{{
|
||||
attachment.fileName
|
||||
}}</span>
|
||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||
<i
|
||||
*ngIf="attachment.key == null"
|
||||
slot="default-trailing"
|
||||
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
|
||||
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
|
||||
></i>
|
||||
</bit-item-content>
|
||||
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
@if (attachment.key != null) {
|
||||
<app-download-attachment
|
||||
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||
[cipher]="cipher()"
|
||||
[attachment]="attachment"
|
||||
></app-download-attachment>
|
||||
} @else {
|
||||
<button
|
||||
[bitAction]="fixOldAttachment(attachment)"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
size="small"
|
||||
type="button"
|
||||
>
|
||||
{{ "fixEncryption" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<app-delete-attachment
|
||||
[admin]="admin() && organization()?.canEditAllCiphers"
|
||||
[cipherId]="cipher().id"
|
||||
[attachment]="attachment"
|
||||
(onDeletionSuccess)="removeAttachment(attachment)"
|
||||
></app-delete-attachment>
|
||||
</bit-item-action>
|
||||
</ng-container>
|
||||
</bit-item>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit">
|
||||
<bit-card>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -13,7 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
@@ -26,27 +27,21 @@ import { FakeAccountService, mockAccountServiceWith } from "../../../../../commo
|
||||
import { CipherAttachmentsComponent } from "./cipher-attachments.component";
|
||||
import { DeleteAttachmentComponent } from "./delete-attachment/delete-attachment.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-download-attachment",
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockDownloadAttachmentComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() attachment: AttachmentView;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() cipher: CipherView;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() admin: boolean = false;
|
||||
readonly attachment = input<AttachmentView>();
|
||||
readonly cipher = input<CipherView>();
|
||||
readonly admin = input<boolean>(false);
|
||||
}
|
||||
|
||||
describe("CipherAttachmentsComponent", () => {
|
||||
let component: CipherAttachmentsComponent;
|
||||
let fixture: ComponentFixture<CipherAttachmentsComponent>;
|
||||
let submitBtnFixture: ComponentFixture<ButtonComponent>;
|
||||
const showToast = jest.fn();
|
||||
const cipherView = {
|
||||
id: "5555-444-3333",
|
||||
@@ -63,17 +58,21 @@ describe("CipherAttachmentsComponent", () => {
|
||||
};
|
||||
|
||||
const organization = new Organization();
|
||||
organization.id = "org-123" as OrganizationId;
|
||||
organization.type = OrganizationUserType.Admin;
|
||||
organization.allowAdminAccessToAllCollectionItems = true;
|
||||
|
||||
const cipherServiceGet = jest.fn().mockResolvedValue(cipherDomain);
|
||||
const cipherServiceDecrypt = jest.fn().mockResolvedValue(cipherView);
|
||||
const saveAttachmentWithServer = jest.fn().mockResolvedValue(cipherDomain);
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([organization]);
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherServiceGet.mockClear();
|
||||
cipherServiceDecrypt.mockClear().mockResolvedValue(cipherView);
|
||||
showToast.mockClear();
|
||||
saveAttachmentWithServer.mockClear().mockResolvedValue(cipherDomain);
|
||||
|
||||
@@ -87,7 +86,7 @@ describe("CipherAttachmentsComponent", () => {
|
||||
get: cipherServiceGet,
|
||||
saveAttachmentWithServer,
|
||||
getKeyForCipherKeyDecryption: () => Promise.resolve(null),
|
||||
decrypt: jest.fn().mockResolvedValue(cipherView),
|
||||
decrypt: cipherServiceDecrypt,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -110,7 +109,9 @@ describe("CipherAttachmentsComponent", () => {
|
||||
},
|
||||
{
|
||||
provide: OrganizationService,
|
||||
useValue: mock<OrganizationService>(),
|
||||
useValue: {
|
||||
organizations$: () => organizations$.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -128,70 +129,67 @@ describe("CipherAttachmentsComponent", () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.cipherId = "5555-444-3333" as CipherId;
|
||||
component.submitBtn = TestBed.createComponent(ButtonComponent).componentInstance;
|
||||
submitBtnFixture = TestBed.createComponent(ButtonComponent);
|
||||
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to wait for the async initialization effect to complete
|
||||
*/
|
||||
async function waitForInitialization(): Promise<void> {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it("fetches cipherView using `cipherId`", async () => {
|
||||
await component.ngOnInit();
|
||||
await waitForInitialization();
|
||||
|
||||
expect(cipherServiceGet).toHaveBeenCalledWith("5555-444-3333", mockUserId);
|
||||
expect(component.cipher).toEqual(cipherView);
|
||||
});
|
||||
|
||||
it("sets testids for automation testing", () => {
|
||||
it("sets testids for automation testing", async () => {
|
||||
const attachment = {
|
||||
id: "1234-5678",
|
||||
fileName: "test file.txt",
|
||||
sizeName: "244.2 KB",
|
||||
} as AttachmentView;
|
||||
|
||||
component.cipher.attachments = [attachment];
|
||||
const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
|
||||
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
|
||||
|
||||
// Create fresh fixture to pick up the mock
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
fixture.detectChanges();
|
||||
|
||||
await waitForInitialization();
|
||||
|
||||
const fileName = fixture.debugElement.query(By.css('[data-testid="file-name"]'));
|
||||
const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]'));
|
||||
|
||||
expect(fileName.nativeElement.textContent).toEqual(attachment.fileName);
|
||||
expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName);
|
||||
expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName);
|
||||
});
|
||||
|
||||
describe("bitSubmit", () => {
|
||||
beforeEach(() => {
|
||||
component.submitBtn.disabled.set(undefined);
|
||||
component.submitBtn.loading.set(undefined);
|
||||
});
|
||||
|
||||
it("updates sets initial state of the submit button", async () => {
|
||||
await component.ngOnInit();
|
||||
// Create fresh fixture to properly test initial state
|
||||
submitBtnFixture = TestBed.createComponent(ButtonComponent);
|
||||
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
|
||||
|
||||
expect(component.submitBtn.disabled()).toBe(true);
|
||||
});
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
fixture.detectChanges();
|
||||
|
||||
it("sets submitBtn loading state", () => {
|
||||
jest.useFakeTimers();
|
||||
await waitForInitialization();
|
||||
|
||||
component.bitSubmit.loading = true;
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(component.submitBtn.loading()).toBe(true);
|
||||
|
||||
component.bitSubmit.loading = false;
|
||||
|
||||
expect(component.submitBtn.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it("sets submitBtn disabled state", () => {
|
||||
component.bitSubmit.disabled = true;
|
||||
|
||||
expect(component.submitBtn.disabled()).toBe(true);
|
||||
|
||||
component.bitSubmit.disabled = false;
|
||||
|
||||
expect(component.submitBtn.disabled()).toBe(false);
|
||||
expect(submitBtnFixture.componentInstance.disabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +197,7 @@ describe("CipherAttachmentsComponent", () => {
|
||||
let file: File;
|
||||
|
||||
beforeEach(() => {
|
||||
component.submitBtn.disabled.set(undefined);
|
||||
submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean);
|
||||
file = new File([""], "attachment.txt", { type: "text/plain" });
|
||||
|
||||
const inputElement = fixture.debugElement.query(By.css("input[type=file]"));
|
||||
@@ -215,11 +213,11 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("sets value of `file` control when input changes", () => {
|
||||
expect(component.attachmentForm.controls.file.value.name).toEqual(file.name);
|
||||
expect(component.attachmentForm.controls.file.value?.name).toEqual(file.name);
|
||||
});
|
||||
|
||||
it("updates disabled state of submit button", () => {
|
||||
expect(component.submitBtn.disabled()).toBe(false);
|
||||
expect(submitBtnFixture.componentInstance.disabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +248,8 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("shows error toast with server message when saveAttachmentWithServer fails", async () => {
|
||||
await waitForInitialization();
|
||||
|
||||
const file = { size: 100 } as File;
|
||||
component.attachmentForm.controls.file.setValue(file);
|
||||
|
||||
@@ -265,6 +265,8 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("shows error toast with fallback message when error has no message property", async () => {
|
||||
await waitForInitialization();
|
||||
|
||||
const file = { size: 100 } as File;
|
||||
component.attachmentForm.controls.file.setValue(file);
|
||||
|
||||
@@ -279,6 +281,8 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("shows error toast with string error message", async () => {
|
||||
await waitForInitialization();
|
||||
|
||||
const file = { size: 100 } as File;
|
||||
component.attachmentForm.controls.file.setValue(file);
|
||||
|
||||
@@ -296,13 +300,27 @@ describe("CipherAttachmentsComponent", () => {
|
||||
describe("success", () => {
|
||||
const file = { size: 524287999 } as File;
|
||||
|
||||
beforeEach(() => {
|
||||
async function setupWithOrganization(adminAccess: boolean): Promise<void> {
|
||||
// Create fresh fixture with organization set before cipherId
|
||||
organization.allowAdminAccessToAllCollectionItems = adminAccess;
|
||||
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
submitBtnFixture = TestBed.createComponent(ButtonComponent);
|
||||
|
||||
// Set organizationId BEFORE cipherId so the effect picks it up
|
||||
fixture.componentRef.setInput("organizationId", organization.id);
|
||||
fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance);
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
fixture.detectChanges();
|
||||
|
||||
await waitForInitialization();
|
||||
component.attachmentForm.controls.file.setValue(file);
|
||||
component.organization = organization;
|
||||
});
|
||||
}
|
||||
|
||||
it("calls `saveAttachmentWithServer` with admin=false when admin permission is false for organization", async () => {
|
||||
component.organization.allowAdminAccessToAllCollectionItems = false;
|
||||
await setupWithOrganization(false);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(saveAttachmentWithServer).toHaveBeenCalledWith(
|
||||
@@ -314,13 +332,16 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("calls `saveAttachmentWithServer` with admin=true when using admin API", async () => {
|
||||
component.organization.allowAdminAccessToAllCollectionItems = true;
|
||||
await setupWithOrganization(true);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId, true);
|
||||
});
|
||||
|
||||
it("resets form and input values", async () => {
|
||||
await setupWithOrganization(true);
|
||||
|
||||
await component.submit();
|
||||
|
||||
const fileInput = fixture.debugElement.query(By.css("input[type=file]"));
|
||||
@@ -330,16 +351,19 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
it("shows success toast", async () => {
|
||||
await setupWithOrganization(true);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: "attachmentSaved",
|
||||
});
|
||||
});
|
||||
|
||||
it('emits "onUploadSuccess"', async () => {
|
||||
await setupWithOrganization(true);
|
||||
|
||||
const emitSpy = jest.spyOn(component.onUploadSuccess, "emit");
|
||||
|
||||
await component.submit();
|
||||
@@ -350,22 +374,36 @@ describe("CipherAttachmentsComponent", () => {
|
||||
});
|
||||
|
||||
describe("removeAttachment", () => {
|
||||
const attachment = { id: "1234-5678" } as AttachmentView;
|
||||
const attachment = { id: "1234-5678", fileName: "test.txt" } as AttachmentView;
|
||||
|
||||
beforeEach(() => {
|
||||
component.cipher.attachments = [attachment];
|
||||
it("removes attachment from cipher", async () => {
|
||||
// Create a new fixture with cipher that has attachments
|
||||
const cipherWithAttachments = { ...cipherView, attachments: [attachment] };
|
||||
cipherServiceDecrypt.mockResolvedValue(cipherWithAttachments);
|
||||
|
||||
// Create fresh fixture
|
||||
fixture = TestBed.createComponent(CipherAttachmentsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("removes attachment from cipher", () => {
|
||||
await waitForInitialization();
|
||||
|
||||
// Verify attachment is rendered
|
||||
const attachmentsBefore = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
|
||||
expect(attachmentsBefore.length).toEqual(1);
|
||||
|
||||
const deleteAttachmentComponent = fixture.debugElement.query(
|
||||
By.directive(DeleteAttachmentComponent),
|
||||
).componentInstance as DeleteAttachmentComponent;
|
||||
|
||||
deleteAttachmentComponent.onDeletionSuccess.emit();
|
||||
|
||||
expect(component.cipher.attachments).toEqual([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
// After removal, there should be no attachments displayed
|
||||
const attachmentItems = fixture.debugElement.queryAll(By.css('[data-testid="file-name"]'));
|
||||
expect(attachmentItems.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
@@ -56,11 +54,10 @@ type CipherAttachmentForm = FormGroup<{
|
||||
file: FormControl<File | null>;
|
||||
}>;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-cipher-attachments",
|
||||
templateUrl: "./cipher-attachments.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
@@ -74,60 +71,50 @@ type CipherAttachmentForm = FormGroup<{
|
||||
DownloadAttachmentComponent,
|
||||
],
|
||||
})
|
||||
export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
export class CipherAttachmentsComponent {
|
||||
/** `id` associated with the form element */
|
||||
static attachmentFormID = "attachmentForm";
|
||||
|
||||
/** Reference to the file HTMLInputElement */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild("fileInput", { read: ElementRef }) private fileInput: ElementRef<HTMLInputElement>;
|
||||
private readonly fileInput = viewChild("fileInput", { read: ElementRef<HTMLInputElement> });
|
||||
|
||||
/** Reference to the BitSubmitDirective */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild(BitSubmitDirective) bitSubmit: BitSubmitDirective;
|
||||
readonly bitSubmit = viewChild(BitSubmitDirective);
|
||||
|
||||
/** The `id` of the cipher in context */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) cipherId: CipherId;
|
||||
readonly cipherId = input.required<CipherId>();
|
||||
|
||||
/** The organization ID if this cipher belongs to an organization */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() organizationId?: OrganizationId;
|
||||
readonly organizationId = input<OrganizationId>();
|
||||
|
||||
/** Denotes if the action is occurring from within the admin console */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() admin: boolean = false;
|
||||
readonly admin = input<boolean>(false);
|
||||
|
||||
/** An optional submit button, whose loading/disabled state will be tied to the form state. */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() submitBtn?: ButtonComponent;
|
||||
readonly submitBtn = input<ButtonComponent>();
|
||||
|
||||
/** Emits when a file upload is started */
|
||||
readonly onUploadStarted = output<void>();
|
||||
|
||||
/** Emits after a file has been successfully uploaded */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() onUploadSuccess = new EventEmitter<void>();
|
||||
readonly onUploadSuccess = output<void>();
|
||||
|
||||
/** Emits when a file upload fails */
|
||||
readonly onUploadFailed = output<void>();
|
||||
|
||||
/** Emits after a file has been successfully removed */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() onRemoveSuccess = new EventEmitter<void>();
|
||||
readonly onRemoveSuccess = output<void>();
|
||||
|
||||
organization: Organization;
|
||||
cipher: CipherView;
|
||||
protected readonly organization = signal<Organization | null>(null);
|
||||
protected readonly cipher = signal<CipherView | null>(null);
|
||||
|
||||
attachmentForm: CipherAttachmentForm = this.formBuilder.group({
|
||||
file: new FormControl<File>(null, [Validators.required]),
|
||||
file: new FormControl<File | null>(null, [Validators.required]),
|
||||
});
|
||||
|
||||
private cipherDomain: Cipher;
|
||||
private activeUserId: UserId;
|
||||
private destroy$ = inject(DestroyRef);
|
||||
private cipherDomain: Cipher | null = null;
|
||||
private activeUserId: UserId | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
@@ -140,43 +127,52 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
private organizationService: OrganizationService,
|
||||
) {
|
||||
this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => {
|
||||
if (!this.submitBtn) {
|
||||
const btn = this.submitBtn();
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.disabled.set(status !== "VALID");
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
// Get the organization to check admin permissions
|
||||
this.organization = await this.getOrganization();
|
||||
this.cipherDomain = await this.getCipher(this.cipherId);
|
||||
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId);
|
||||
|
||||
// Update the initial state of the submit button
|
||||
if (this.submitBtn) {
|
||||
this.submitBtn.disabled.set(!this.attachmentForm.valid);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((loading) => {
|
||||
if (!this.submitBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.loading.set(loading);
|
||||
btn.disabled.set(status !== "VALID");
|
||||
});
|
||||
|
||||
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => {
|
||||
if (!this.submitBtn) {
|
||||
// Initialize data when cipherId input is available
|
||||
effect(async () => {
|
||||
const cipherId = this.cipherId();
|
||||
if (!cipherId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.disabled.set(disabled);
|
||||
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
// Get the organization to check admin permissions
|
||||
this.organization.set(await this.getOrganization());
|
||||
this.cipherDomain = await this.getCipher(cipherId);
|
||||
|
||||
if (this.cipherDomain && this.activeUserId) {
|
||||
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
|
||||
}
|
||||
|
||||
// Update the initial state of the submit button
|
||||
const btn = this.submitBtn();
|
||||
if (btn) {
|
||||
btn.disabled.set(!this.attachmentForm.valid);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync bitSubmit loading/disabled state with submitBtn
|
||||
effect(() => {
|
||||
const bitSubmit = this.bitSubmit();
|
||||
const btn = this.submitBtn();
|
||||
if (!bitSubmit || !btn) {
|
||||
return;
|
||||
}
|
||||
|
||||
bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
|
||||
btn.loading.set(loading);
|
||||
});
|
||||
|
||||
bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
|
||||
btn.disabled.set(disabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,8 +192,10 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/** Save the attachments to the cipher */
|
||||
submit = async () => {
|
||||
this.onUploadStarted.emit();
|
||||
|
||||
const file = this.attachmentForm.value.file;
|
||||
if (file === null) {
|
||||
if (file == null) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
@@ -216,24 +214,30 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.cipherDomain || !this.activeUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.cipherDomain = await this.cipherService.saveAttachmentWithServer(
|
||||
this.cipherDomain,
|
||||
file,
|
||||
this.activeUserId,
|
||||
this.organization?.canEditAllCiphers,
|
||||
this.organization()?.canEditAllCiphers,
|
||||
);
|
||||
|
||||
// re-decrypt the cipher to update the attachments
|
||||
this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId);
|
||||
this.cipher.set(await this.cipherService.decrypt(this.cipherDomain, this.activeUserId));
|
||||
|
||||
// Reset reactive form and input element
|
||||
this.fileInput.nativeElement.value = "";
|
||||
const fileInputEl = this.fileInput();
|
||||
if (fileInputEl) {
|
||||
fileInputEl.nativeElement.value = "";
|
||||
}
|
||||
this.attachmentForm.controls.file.setValue(null);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("attachmentSaved"),
|
||||
});
|
||||
|
||||
@@ -245,7 +249,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
let errorMessage = this.i18nService.t("unexpectedError");
|
||||
if (typeof e === "string") {
|
||||
errorMessage = e;
|
||||
} else if (e?.message) {
|
||||
} else if (e instanceof Error && e?.message) {
|
||||
errorMessage = e.message;
|
||||
}
|
||||
|
||||
@@ -253,15 +257,25 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
variant: "error",
|
||||
message: errorMessage,
|
||||
});
|
||||
this.onUploadFailed.emit();
|
||||
}
|
||||
};
|
||||
|
||||
/** Removes the attachment from the cipher */
|
||||
removeAttachment(attachment: AttachmentView) {
|
||||
const index = this.cipher.attachments.indexOf(attachment);
|
||||
const currentCipher = this.cipher();
|
||||
if (!currentCipher?.attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = currentCipher.attachments.indexOf(attachment);
|
||||
|
||||
if (index > -1) {
|
||||
this.cipher.attachments.splice(index, 1);
|
||||
currentCipher.attachments.splice(index, 1);
|
||||
// Trigger signal update by creating a new reference
|
||||
this.cipher.set(
|
||||
Object.assign(Object.create(Object.getPrototypeOf(currentCipher)), currentCipher),
|
||||
);
|
||||
}
|
||||
|
||||
this.onRemoveSuccess.emit();
|
||||
@@ -273,7 +287,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
* it will retrieve the cipher using the admin endpoint.
|
||||
*/
|
||||
private async getCipher(id: CipherId): Promise<Cipher | null> {
|
||||
if (id == null) {
|
||||
if (id == null || !this.activeUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -281,12 +295,13 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
const localCipher = await this.cipherService.get(id, this.activeUserId);
|
||||
|
||||
// If we got the cipher or there's no organization context, return the result
|
||||
if (localCipher != null || !this.organizationId) {
|
||||
if (localCipher != null || !this.organizationId()) {
|
||||
return localCipher;
|
||||
}
|
||||
|
||||
// Only try the admin API if the user has admin permissions
|
||||
if (this.organization != null && this.organization.canEditAllCiphers) {
|
||||
const org = this.organization();
|
||||
if (org != null && org.canEditAllCiphers) {
|
||||
const cipherResponse = await this.apiService.getCipherAdmin(id);
|
||||
const cipherData = new CipherData(cipherResponse);
|
||||
return new Cipher(cipherData);
|
||||
@@ -299,7 +314,8 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
* Gets the organization for the given organization ID
|
||||
*/
|
||||
private async getOrganization(): Promise<Organization | null> {
|
||||
if (!this.organizationId) {
|
||||
const orgId = this.organizationId();
|
||||
if (!orgId || !this.activeUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -307,6 +323,41 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit {
|
||||
this.organizationService.organizations$(this.activeUserId),
|
||||
);
|
||||
|
||||
return organizations.find((o) => o.id === this.organizationId) || null;
|
||||
return organizations.find((o) => o.id === orgId) || null;
|
||||
}
|
||||
|
||||
protected fixOldAttachment = (attachment: AttachmentView) => {
|
||||
return async () => {
|
||||
const cipher = this.cipher();
|
||||
const userId = this.activeUserId;
|
||||
|
||||
if (!attachment.id || !userId || !cipher) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedCipher = await this.cipherService.upgradeOldCipherAttachments(
|
||||
cipher,
|
||||
userId,
|
||||
attachment.id,
|
||||
);
|
||||
|
||||
this.cipher.set(updatedCipher);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("attachmentUpdated"),
|
||||
});
|
||||
this.onUploadSuccess.emit();
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogService,
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type AdvancedUriOptionDialogParams = {
|
||||
@@ -55,6 +56,7 @@ export class AdvancedUriOptionDialogComponent {
|
||||
return dialogService.open<boolean>(AdvancedUriOptionDialogComponent, {
|
||||
data: params,
|
||||
disableClose: true,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,12 +153,12 @@ describe("UriOptionComponent", () => {
|
||||
component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact });
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"showMatchDetection https://example.com",
|
||||
"showMatchDetectionNoPlaceholder",
|
||||
);
|
||||
getToggleMatchDetectionBtn().click();
|
||||
fixture.detectChanges();
|
||||
expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
|
||||
"hideMatchDetection https://example.com",
|
||||
"hideMatchDetectionNoPlaceholder",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +165,11 @@ export class UriOptionComponent implements ControlValueAccessor {
|
||||
}
|
||||
|
||||
protected get toggleTitle() {
|
||||
return this.showMatchDetection
|
||||
? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri)
|
||||
: this.i18nService.t("showMatchDetection", this.uriForm.value.uri);
|
||||
return this.i18nService.t(
|
||||
this.showMatchDetection
|
||||
? "hideMatchDetectionNoPlaceholder"
|
||||
: "showMatchDetectionNoPlaceholder",
|
||||
);
|
||||
}
|
||||
|
||||
// NG_VALUE_ACCESSOR implementation
|
||||
|
||||
@@ -42,9 +42,18 @@ describe("CipherFormComponent", () => {
|
||||
{ provide: CipherFormService, useValue: mockAddEditFormService },
|
||||
{
|
||||
provide: CipherFormCacheService,
|
||||
useValue: { init: jest.fn(), getCachedCipherView: jest.fn() },
|
||||
useValue: { init: jest.fn(), getCachedCipherView: jest.fn(), clearCache: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: ViewCacheService,
|
||||
useValue: {
|
||||
signal: jest.fn(() => {
|
||||
const signalFn = (): any => null;
|
||||
signalFn.set = jest.fn();
|
||||
return signalFn;
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
@@ -154,13 +163,13 @@ describe("CipherFormComponent", () => {
|
||||
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
|
||||
});
|
||||
|
||||
it("clears archiveDate on updatedCipherView", async () => {
|
||||
it("does not clear archiveDate on updatedCipherView", async () => {
|
||||
cipherView.archivedDate = new Date();
|
||||
decryptCipher.mockResolvedValue(cipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBe(cipherView.archivedDate);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -281,7 +281,6 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
|
||||
if (this.config.mode === "clone") {
|
||||
this.updatedCipherView.id = null;
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
|
||||
if (this.updatedCipherView.login) {
|
||||
this.updatedCipherView.login.fido2Credentials = null;
|
||||
@@ -305,13 +304,30 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
* Updates `updatedCipherView` based on the value from the cache.
|
||||
*/
|
||||
setInitialCipherFromCache() {
|
||||
// If we are coming from the overlay/popup flow clear the cache to avoid old cached data
|
||||
const hasOverlayData =
|
||||
this.config.initialValues &&
|
||||
(this.config.initialValues.username !== undefined ||
|
||||
this.config.initialValues.password !== undefined);
|
||||
|
||||
if (hasOverlayData) {
|
||||
this.cipherFormCacheService.clearCache();
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedCipher = this.cipherFormCacheService.getCachedCipherView();
|
||||
if (cachedCipher === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the cached cipher when it matches the cipher being edited
|
||||
if (this.updatedCipherView.id === cachedCipher.id) {
|
||||
const isEditingExistingCipher =
|
||||
this.updatedCipherView.id && this.updatedCipherView.id === cachedCipher.id;
|
||||
const isCreatingNewCipher =
|
||||
!this.updatedCipherView.id &&
|
||||
!cachedCipher.id &&
|
||||
this.updatedCipherView.type === cachedCipher.type;
|
||||
|
||||
if (isEditingExistingCipher || isCreatingNewCipher) {
|
||||
this.updatedCipherView = cachedCipher;
|
||||
}
|
||||
}
|
||||
@@ -342,6 +358,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
let successToast: string = "editedItem";
|
||||
if (this.cipherForm.invalid) {
|
||||
this.cipherForm.markAllAsTouched();
|
||||
|
||||
@@ -376,6 +393,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
// If the item is archived but user has lost archive permissions, unarchive the item.
|
||||
if (!userCanArchive && this.updatedCipherView.archivedDate) {
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
successToast = "itemRestored";
|
||||
}
|
||||
|
||||
const savedCipher = await this.addEditFormService.saveCipher(
|
||||
@@ -383,12 +401,15 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
this.config,
|
||||
);
|
||||
|
||||
// Clear the cache after successful save
|
||||
this.cipherFormCacheService.clearCache();
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
this.config.mode === "edit" || this.config.mode === "partial-edit"
|
||||
? "editedItem"
|
||||
? successToast
|
||||
: "addedItem",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<section [formGroup]="itemDetailsForm" class="tw-mb-5 bit-compact:tw-mb-4">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
|
||||
@if (showArchiveBadge()) {
|
||||
<span bitBadge> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
<button
|
||||
*ngIf="!config.hideIndividualVaultFields"
|
||||
slot="end"
|
||||
|
||||
@@ -5,16 +5,21 @@ import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { SelectComponent } from "@bitwarden/components";
|
||||
@@ -62,6 +67,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let mockCipherArchiveService: MockProxy<CipherArchiveService>;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
|
||||
const getInitialCipherView = jest.fn<CipherView | null, []>(() => null);
|
||||
@@ -90,6 +97,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of([]));
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
mockCipherArchiveService = mock<CipherArchiveService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
@@ -99,6 +108,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -209,9 +220,10 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
describe("allowOwnershipChange", () => {
|
||||
it("should not allow ownership change if in edit mode and the cipher is owned by an organization", () => {
|
||||
component.config.mode = "edit";
|
||||
component.originalCipherView = {
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
organizationId: "org1",
|
||||
} as CipherView;
|
||||
} as CipherView);
|
||||
|
||||
expect(component.allowOwnershipChange).toBe(false);
|
||||
});
|
||||
|
||||
@@ -251,7 +263,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
it("should show organization data ownership when the configuration allows", () => {
|
||||
component.config.mode = "edit";
|
||||
component.config.organizationDataOwnershipDisabled = true;
|
||||
component.originalCipherView = {} as CipherView;
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
component.config.organizations = [{ id: "134-433-22" } as Organization];
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -265,7 +277,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
it("should show organization data ownership when the control is disabled", async () => {
|
||||
component.config.mode = "edit";
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.originalCipherView = {} as CipherView;
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
component.config.organizations = [{ id: "134-433-22" } as Organization];
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
@@ -360,18 +372,20 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("should select the first organization if organization data ownership is enabled", async () => {
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
} as CipherView;
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
];
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -469,20 +483,17 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
it("should show readonly hint if readonly collections are present", async () => {
|
||||
component.config.mode = "edit";
|
||||
getInitialCipherView.mockReturnValueOnce({
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView);
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
getInitialCipherView.mockReturnValueOnce(updatedCipher);
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
createMockCollection("col1", "Collection 1", "org1", true, false) as CollectionView,
|
||||
@@ -539,13 +550,16 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
i < 4 ? CollectionTypes.SharedCollection : CollectionTypes.DefaultUserCollection,
|
||||
) as CollectionView,
|
||||
);
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -567,7 +581,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
createMockCollection("col2", "Collection 2", "org1", false, true) as CollectionView,
|
||||
createMockCollection("col3", "Collection 3", "org1", true, false) as CollectionView,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
|
||||
const currentCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
@@ -575,7 +590,9 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
fixture.componentRef.setInput("originalCipherView", currentCipher);
|
||||
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
});
|
||||
@@ -604,7 +621,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
];
|
||||
component.originalCipherView = {} as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
@@ -684,13 +702,16 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
beforeEach(() => {
|
||||
component.config.mode = "edit";
|
||||
component.config.originalCipher = new Cipher();
|
||||
component.originalCipherView = {
|
||||
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
});
|
||||
|
||||
describe("when personal ownership is not allowed", () => {
|
||||
@@ -701,7 +722,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("cipher does not belong to an organization", () => {
|
||||
beforeEach(() => {
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView!);
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView()!);
|
||||
});
|
||||
|
||||
it("enables organizationId", async () => {
|
||||
@@ -720,8 +741,11 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("cipher belongs to an organization", () => {
|
||||
beforeEach(() => {
|
||||
component.originalCipherView.organizationId = "org-id";
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
...component.originalCipherView(),
|
||||
organizationId: "org-id",
|
||||
} as CipherView);
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
});
|
||||
|
||||
it("enables the rest of the form", async () => {
|
||||
@@ -734,8 +758,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("setFormState behavior with null/undefined", () => {
|
||||
it("calls disableFormFields when organizationId value is null", async () => {
|
||||
component.originalCipherView.organizationId = null as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = null as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -743,8 +767,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("calls disableFormFields when organizationId value is undefined", async () => {
|
||||
component.originalCipherView.organizationId = undefined;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = undefined;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -752,8 +776,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("calls enableFormFields when organizationId has a string value", async () => {
|
||||
component.originalCipherView.organizationId = "org-id" as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = "org-id" as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -765,11 +789,11 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
describe("when an ownership change is not allowed", () => {
|
||||
beforeEach(() => {
|
||||
component.config.organizationDataOwnershipDisabled = true; // allow personal ownership
|
||||
component.originalCipherView!.organizationId = undefined;
|
||||
component.originalCipherView()!.organizationId = undefined;
|
||||
});
|
||||
|
||||
it("disables organizationId when the cipher is owned by an organization", async () => {
|
||||
component.originalCipherView!.organizationId = "orgId";
|
||||
component.originalCipherView()!.organizationId = "orgId";
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -785,4 +809,28 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("showArchiveBadge", () => {
|
||||
it("should set showArchiveBadge to true when cipher is archived and client is Desktop", async () => {
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
|
||||
const archivedCipher = {
|
||||
name: "archived cipher",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
isArchived: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", archivedCipher);
|
||||
|
||||
getInitialCipherView.mockReturnValueOnce(archivedCipher);
|
||||
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["showArchiveBadge"]()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
||||
import { Component, computed, DestroyRef, input, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeComponent,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
@@ -52,6 +55,7 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
IconButtonModule,
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
BadgeComponent,
|
||||
],
|
||||
})
|
||||
export class ItemDetailsSectionComponent implements OnInit {
|
||||
@@ -63,6 +67,14 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
favorite: [false],
|
||||
});
|
||||
|
||||
protected readonly showArchiveBadge = computed(() => {
|
||||
return (
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$ &&
|
||||
this.originalCipherView()?.isArchived &&
|
||||
this.platformUtilsService.getClientType() === ClientType.Desktop
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Collection options available for the selected organization.
|
||||
* @protected
|
||||
@@ -91,10 +103,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
@Input({ required: true })
|
||||
config: CipherFormConfig;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
originalCipherView: CipherView;
|
||||
readonly originalCipherView = input<CipherView>();
|
||||
|
||||
get readOnlyCollectionsNames(): string[] {
|
||||
return this.readOnlyCollections.map((c) => c.name);
|
||||
@@ -141,8 +150,9 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
private i18nService: I18nService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private policyService: PolicyService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
||||
this.itemDetailsForm.valueChanges
|
||||
@@ -178,7 +188,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
|
||||
get allowOwnershipChange() {
|
||||
// Do not allow ownership change in edit mode and the cipher is owned by an organization
|
||||
if (this.config.mode === "edit" && this.originalCipherView?.organizationId != null) {
|
||||
if (this.config.mode === "edit" && this.originalCipherView()?.organizationId != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -192,11 +202,6 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
}
|
||||
|
||||
get showOwnership() {
|
||||
// Don't show ownership field for archived ciphers
|
||||
if (this.originalCipherView?.isArchived) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show ownership field when editing with available orgs
|
||||
const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit";
|
||||
|
||||
@@ -290,14 +295,6 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CreateDefaultLocation,
|
||||
);
|
||||
|
||||
if (!isFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOrgHasPolicyEnabled = (
|
||||
await firstValueFrom(
|
||||
this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, this.userId),
|
||||
@@ -365,7 +362,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
(c) =>
|
||||
c.organizationId === orgId &&
|
||||
c.readOnly &&
|
||||
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
|
||||
this.originalCipherView().collectionIds.includes(c.id as CollectionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -422,8 +419,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
* Note: `.every` will return true for an empty array
|
||||
*/
|
||||
const cipherIsOnlyInOrgCollections =
|
||||
(this.originalCipherView?.collectionIds ?? []).length > 0 &&
|
||||
this.originalCipherView.collectionIds.every(
|
||||
(this.originalCipherView()?.collectionIds ?? []).length > 0 &&
|
||||
this.originalCipherView().collectionIds.every(
|
||||
(cId) =>
|
||||
this.collections.find((c) => c.id === cId)?.type === CollectionTypes.SharedCollection,
|
||||
);
|
||||
|
||||
@@ -15,15 +15,16 @@
|
||||
data-testid="toggle-privateKey-visibility"
|
||||
bitPasswordInputToggle
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-import"
|
||||
bitSuffix
|
||||
data-testid="import-privateKey"
|
||||
*ngIf="showImport"
|
||||
label="{{ 'importSshKeyFromClipboard' | i18n }}"
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
></button>
|
||||
@if (showImport()) {
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-import"
|
||||
bitSuffix
|
||||
data-testid="import-privateKey"
|
||||
label="{{ 'importSshKeyFromClipboard' | i18n }}"
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
></button>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { generate_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { SshKeySectionComponent } from "./sshkey-section.component";
|
||||
|
||||
jest.mock("@bitwarden/sdk-internal", () => {
|
||||
return {
|
||||
generate_ssh_key: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("SshKeySectionComponent", () => {
|
||||
let fixture: ComponentFixture<SshKeySectionComponent>;
|
||||
let component: SshKeySectionComponent;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
|
||||
let formStatusChange$: Subject<string>;
|
||||
|
||||
let cipherFormContainer: {
|
||||
registerChildForm: jest.Mock;
|
||||
patchCipher: jest.Mock;
|
||||
getInitialCipherView: jest.Mock;
|
||||
formStatusChange$: Subject<string>;
|
||||
};
|
||||
|
||||
let sdkClient$: BehaviorSubject<unknown>;
|
||||
let sdkService: { client$: BehaviorSubject<unknown> };
|
||||
|
||||
let sshImportPromptService: { importSshKeyFromClipboard: jest.Mock };
|
||||
|
||||
let platformUtilsService: { getClientType: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
formStatusChange$ = new Subject<string>();
|
||||
|
||||
cipherFormContainer = {
|
||||
registerChildForm: jest.fn(),
|
||||
patchCipher: jest.fn(),
|
||||
getInitialCipherView: jest.fn(),
|
||||
formStatusChange$,
|
||||
};
|
||||
|
||||
sdkClient$ = new BehaviorSubject<unknown>({});
|
||||
sdkService = { client$: sdkClient$ };
|
||||
|
||||
sshImportPromptService = {
|
||||
importSshKeyFromClipboard: jest.fn(),
|
||||
};
|
||||
|
||||
platformUtilsService = {
|
||||
getClientType: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SshKeySectionComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: CipherFormContainer, useValue: cipherFormContainer },
|
||||
{ provide: SdkService, useValue: sdkService },
|
||||
{ provide: SshImportPromptService, useValue: sshImportPromptService },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SshKeySectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// minimal required inputs
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
|
||||
fixture.componentRef.setInput("disabled", false);
|
||||
|
||||
(generate_ssh_key as unknown as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
it("registers the sshKeyDetails form with the container in the constructor", () => {
|
||||
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledTimes(1);
|
||||
expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith(
|
||||
"sshKeyDetails",
|
||||
component.sshKeyForm,
|
||||
);
|
||||
});
|
||||
|
||||
it("patches cipher sshKey whenever the form changes", () => {
|
||||
component.sshKeyForm.setValue({
|
||||
privateKey: "priv",
|
||||
publicKey: "pub",
|
||||
keyFingerprint: "fp",
|
||||
});
|
||||
|
||||
expect(cipherFormContainer.patchCipher).toHaveBeenCalledTimes(1);
|
||||
const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0] as (c: any) => any;
|
||||
|
||||
const cipher: any = {};
|
||||
const patched = patchFn(cipher);
|
||||
|
||||
expect(patched.sshKey).toBeInstanceOf(SshKeyView);
|
||||
expect(patched.sshKey.privateKey).toBe("priv");
|
||||
expect(patched.sshKey.publicKey).toBe("pub");
|
||||
expect(patched.sshKey.keyFingerprint).toBe("fp");
|
||||
});
|
||||
|
||||
it("ngOnInit uses initial cipher sshKey (prefill) when present and does not generate", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).not.toHaveBeenCalled();
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("p1");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("p2");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("p3");
|
||||
});
|
||||
|
||||
it("ngOnInit falls back to originalCipherView sshKey when prefill is missing", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
edit: true,
|
||||
sshKey: { privateKey: "o1", publicKey: "o2", keyFingerprint: "o3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).not.toHaveBeenCalled();
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("o1");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("o2");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("o3");
|
||||
});
|
||||
|
||||
it("ngOnInit generates an ssh key when no sshKey exists and populates the form", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue(null);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null });
|
||||
|
||||
(generate_ssh_key as unknown as jest.Mock).mockReturnValue({
|
||||
privateKey: "genPriv",
|
||||
publicKey: "genPub",
|
||||
fingerprint: "genFp",
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(generate_ssh_key).toHaveBeenCalledTimes(1);
|
||||
expect(generate_ssh_key).toHaveBeenCalledWith("Ed25519");
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("genPriv");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("genPub");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("genFp");
|
||||
});
|
||||
|
||||
it("ngOnInit disables the form", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.sshKeyForm.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets showImport true when not Web and originalCipherView.edit is true", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.showImport()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps showImport false when client type is Web", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.showImport()).toBe(false);
|
||||
});
|
||||
|
||||
it("disables the ssh key form when formStatusChange emits enabled", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
component.sshKeyForm.enable();
|
||||
expect(component.sshKeyForm.disabled).toBe(false);
|
||||
|
||||
formStatusChange$.next("enabled");
|
||||
expect(component.sshKeyForm.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the import button only when showImport is true", async () => {
|
||||
cipherFormContainer.getInitialCipherView.mockReturnValue({
|
||||
sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" },
|
||||
});
|
||||
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const importBtn = fixture.debugElement.query(By.css('[data-testid="import-privateKey"]'));
|
||||
expect(importBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("importSshKeyFromClipboard sets form values when a key is returned", async () => {
|
||||
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue({
|
||||
privateKey: "cPriv",
|
||||
publicKey: "cPub",
|
||||
keyFingerprint: "cFp",
|
||||
});
|
||||
|
||||
await component.importSshKeyFromClipboard();
|
||||
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("cPriv");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("cPub");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("cFp");
|
||||
});
|
||||
|
||||
it("importSshKeyFromClipboard does nothing when null is returned", async () => {
|
||||
component.sshKeyForm.setValue({ privateKey: "a", publicKey: "b", keyFingerprint: "c" });
|
||||
sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue(null);
|
||||
|
||||
await component.importSshKeyFromClipboard();
|
||||
|
||||
expect(component.sshKeyForm.get("privateKey")?.value).toBe("a");
|
||||
expect(component.sshKeyForm.get("publicKey")?.value).toBe("b");
|
||||
expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("c");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core";
|
||||
import { Component, computed, DestroyRef, inject, input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -43,15 +43,9 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
],
|
||||
})
|
||||
export class SshKeySectionComponent implements OnInit {
|
||||
/** The original cipher */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() originalCipherView: CipherView;
|
||||
readonly originalCipherView = input<CipherView | null>(null);
|
||||
|
||||
/** True when all fields should be disabled */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() disabled: boolean;
|
||||
readonly disabled = input(false);
|
||||
|
||||
/**
|
||||
* All form fields associated with the ssh key
|
||||
@@ -65,7 +59,14 @@ export class SshKeySectionComponent implements OnInit {
|
||||
keyFingerprint: [""],
|
||||
});
|
||||
|
||||
showImport = false;
|
||||
readonly showImport = computed(() => {
|
||||
return (
|
||||
// Web does not support clipboard access
|
||||
this.platformUtilsService.getClientType() !== ClientType.Web &&
|
||||
this.originalCipherView()?.edit
|
||||
);
|
||||
});
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
@@ -90,7 +91,7 @@ export class SshKeySectionComponent implements OnInit {
|
||||
|
||||
async ngOnInit() {
|
||||
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
|
||||
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView?.sshKey;
|
||||
const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView()?.sshKey;
|
||||
|
||||
if (sshKeyView) {
|
||||
this.setInitialValues(sshKeyView);
|
||||
@@ -100,11 +101,6 @@ export class SshKeySectionComponent implements OnInit {
|
||||
|
||||
this.sshKeyForm.disable();
|
||||
|
||||
// Web does not support clipboard access
|
||||
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
|
||||
this.showImport = true;
|
||||
}
|
||||
|
||||
// Disable the form if the cipher form container is enabled
|
||||
// to prevent user interaction
|
||||
this.cipherFormContainer.formStatusChange$
|
||||
|
||||
@@ -22,7 +22,6 @@ export class CipherFormCacheService {
|
||||
key: CIPHER_FORM_CACHE_KEY,
|
||||
initialValue: null,
|
||||
deserializer: CipherView.fromJSON,
|
||||
clearOnTabChange: true,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -45,4 +44,11 @@ export class CipherFormCacheService {
|
||||
getCachedCipherView(): CipherView | null {
|
||||
return this.cipherCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached CipherView.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cipherCache.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
|
||||
</bit-item-content>
|
||||
<ng-container slot="end">
|
||||
<bit-item-action>
|
||||
<bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6">
|
||||
<app-download-attachment
|
||||
[admin]="admin"
|
||||
[cipher]="cipher"
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
[organizationId]="organizationId"
|
||||
[admin]="admin"
|
||||
[submitBtn]="submitBtn"
|
||||
(onUploadStarted)="uploadStarted()"
|
||||
(onUploadSuccess)="uploadSuccessful()"
|
||||
(onUploadFailed)="uploadFailed()"
|
||||
(onRemoveSuccess)="removalSuccessful()"
|
||||
></app-cipher-attachments>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { Component, HostListener, Inject } from "@angular/core";
|
||||
|
||||
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
@@ -52,6 +52,7 @@ export class AttachmentsV2Component {
|
||||
admin: boolean = false;
|
||||
organizationId?: OrganizationId;
|
||||
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
|
||||
private isUploading = false;
|
||||
|
||||
/**
|
||||
* Constructor for AttachmentsV2Component.
|
||||
@@ -82,16 +83,54 @@ export class AttachmentsV2Component {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent browser tab from closing/refreshing during upload.
|
||||
* Shows a confirmation dialog if user tries to leave during an active upload.
|
||||
* This provides additional protection beyond dialogRef.disableClose.
|
||||
* Using arrow function to preserve 'this' context when used as event listener.
|
||||
*/
|
||||
@HostListener("window:beforeunload", ["$event"])
|
||||
private handleBeforeUnloadEvent = (event: BeforeUnloadEvent): string | undefined => {
|
||||
if (this.isUploading) {
|
||||
event.preventDefault();
|
||||
// The custom message is not displayed in modern browsers, but MDN docs still recommend setting it for legacy support.
|
||||
const message = "Upload in progress. Are you sure you want to leave?";
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when an attachment upload is started.
|
||||
* Disables closing the dialog to prevent accidental interruption.
|
||||
*/
|
||||
uploadStarted() {
|
||||
this.isUploading = true;
|
||||
this.dialogRef.disableClose = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully uploaded.
|
||||
* Closes the dialog with an 'uploaded' result.
|
||||
* Re-enables dialog closing and closes the dialog with an 'uploaded' result.
|
||||
*/
|
||||
uploadSuccessful() {
|
||||
this.isUploading = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
this.dialogRef.close({
|
||||
action: AttachmentDialogResult.Uploaded,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment upload fails.
|
||||
* Re-enables closing the dialog.
|
||||
*/
|
||||
uploadFailed() {
|
||||
this.isUploading = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an attachment is successfully removed.
|
||||
* Closes the dialog with a 'removed' result.
|
||||
|
||||
@@ -5,9 +5,10 @@ import { combineLatest, of, switchMap, map, catchError, from, Observable, startW
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="tw-mb-5 bit-compact:tw-mb-4">
|
||||
<bit-card>
|
||||
<div
|
||||
class="tw-flex tw-place-items-center"
|
||||
class="tw-flex tw-place-items-center tw-w-full"
|
||||
[ngClass]="{
|
||||
'tw-mb-2': allItems.length > 0,
|
||||
}"
|
||||
@@ -10,9 +10,16 @@
|
||||
<div class="tw-flex tw-items-center tw-justify-center" style="width: 40px; height: 40px">
|
||||
<app-vault-icon [cipher]="cipher()" [coloredIcon]="true"></app-vault-icon>
|
||||
</div>
|
||||
<h2 bitTypography="h4" class="tw-ml-2 tw-mt-2" data-testid="item-name">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
class="tw-ml-2 tw-mt-2 tw-select-auto tw-flex-1"
|
||||
data-testid="item-name"
|
||||
>
|
||||
{{ cipher().name }}
|
||||
</h2>
|
||||
@if (showArchiveBadge()) {
|
||||
<span bitBadge> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
</div>
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-flex-col tw-mt-2 md:tw-flex-row md:tw-flex-wrap">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ComponentRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
@@ -21,6 +20,7 @@ describe("ItemDetailsV2Component", () => {
|
||||
let component: ItemDetailsV2Component;
|
||||
let fixture: ComponentFixture<ItemDetailsV2Component>;
|
||||
let componentRef: ComponentRef<ItemDetailsV2Component>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
const cipher = {
|
||||
id: "cipher1",
|
||||
@@ -51,6 +51,8 @@ describe("ItemDetailsV2Component", () => {
|
||||
} as FolderView;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsV2Component],
|
||||
providers: [
|
||||
@@ -61,6 +63,7 @@ describe("ItemDetailsV2Component", () => {
|
||||
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
||||
},
|
||||
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
@@ -98,4 +101,31 @@ describe("ItemDetailsV2Component", () => {
|
||||
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
|
||||
it("should show archive badge when cipher is archived and client is Desktop", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
|
||||
|
||||
const archivedCipher = { ...cipher, isArchived: true };
|
||||
componentRef.setInput("cipher", archivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not show archive badge when cipher is not archived", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
|
||||
|
||||
const unarchivedCipher = { ...cipher, isArchived: false };
|
||||
componentRef.setInput("cipher", unarchivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not show archive badge when client is not Desktop", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Web);
|
||||
|
||||
const archivedCipher = { ...cipher, isArchived: true };
|
||||
componentRef.setInput("cipher", archivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,19 @@ import { Component, computed, input, signal } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { fromEvent, map, startWith } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonLinkDirective,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
@@ -35,6 +40,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
|
||||
OrgIconDirective,
|
||||
FormFieldModule,
|
||||
ButtonLinkDirective,
|
||||
BadgeModule,
|
||||
],
|
||||
})
|
||||
export class ItemDetailsV2Component {
|
||||
@@ -85,7 +91,16 @@ export class ItemDetailsV2Component {
|
||||
}
|
||||
});
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
protected readonly showArchiveBadge = computed(() => {
|
||||
return (
|
||||
this.cipher().isArchived && this.platformUtilsService.getClientType() === ClientType.Desktop
|
||||
);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
toggleShowMore() {
|
||||
this.showAllDetails.update((value) => !value);
|
||||
|
||||
@@ -90,12 +90,15 @@
|
||||
data-testid="copy-password"
|
||||
(click)="logCopyEvent()"
|
||||
></button>
|
||||
<bit-hint *ngIf="showChangePasswordLink">
|
||||
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-hint>
|
||||
@if (showChangePasswordLink) {
|
||||
<bit-hint class="tw-flex tw-mb-3 tw-items-center">
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
|
||||
<span class="tw-ml-2 tw-mr-1">{{ "vulnerablePassword" | i18n }}</span>
|
||||
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
|
||||
{{ "changeNow" | i18n }}
|
||||
</a>
|
||||
</bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
<div
|
||||
*ngIf="showPasswordCount && passwordRevealed"
|
||||
@@ -122,7 +125,7 @@
|
||||
</bit-form-field>
|
||||
<bit-form-field *ngIf="cipher.login.totp">
|
||||
<bit-label [appTextDrag]="totpCodeCopyObj?.totpCode">
|
||||
<div class="tw-flex tw-items-center tw-gap-3">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "verificationCodeTotp" | i18n }}
|
||||
<app-premium-badge [organizationId]="cipher.organizationId"></app-premium-badge>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -34,9 +35,10 @@ describe("LoginCredentialsViewComponent", () => {
|
||||
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(true);
|
||||
const mockAccount = {
|
||||
id: "test-user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
type: 0,
|
||||
status: 0,
|
||||
kdf: 0,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -47,11 +48,7 @@ describe("AddEditFolderDialogComponent", () => {
|
||||
showToast.mockClear();
|
||||
|
||||
const userId = "" as UserId;
|
||||
const accountInfo: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: true,
|
||||
name: undefined,
|
||||
};
|
||||
const accountInfo = mockAccountInfoWith();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddEditFolderDialogComponent, NoopAnimationsModule],
|
||||
|
||||
@@ -5,12 +5,12 @@ import { of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
|
||||
@@ -26,17 +26,17 @@ import {
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
/**
|
||||
* Only shows the element if the user can delete the cipher.
|
||||
@@ -15,7 +15,7 @@ export class CanDeleteCipherDirective implements OnDestroy {
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input("appCanDeleteCipher") set cipher(cipher: CipherView) {
|
||||
@Input("appCanDeleteCipher") set cipher(cipher: CipherViewLike) {
|
||||
this.viewContainer.clear();
|
||||
|
||||
this.cipherAuthorizationService
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
bitIconButton="bwi-angle-left"
|
||||
class="tw-size-6 tw-p-0 tw-flex tw-items-center tw-justify-center"
|
||||
size="small"
|
||||
[attr.label]="'back' | i18n"
|
||||
(click)="prevSlide()"
|
||||
[disabled]="selectedIndex <= 0"
|
||||
appA11yTitle="{{ 'back' | i18n }}"
|
||||
label="{{ 'back' | i18n }}"
|
||||
></button>
|
||||
<div
|
||||
class="tw-w-full tw-flex tw-gap-2 tw-justify-center tw-mt-auto"
|
||||
@@ -34,11 +33,10 @@
|
||||
type="button"
|
||||
bitIconButton="bwi-angle-right"
|
||||
class="tw-size-6 tw-p-0 tw-flex tw-items-center tw-justify-center"
|
||||
[attr.label]="'next' | i18n"
|
||||
size="small"
|
||||
(click)="nextSlide()"
|
||||
[disabled]="selectedIndex >= slides.length - 1"
|
||||
appA11yTitle="{{ 'next' | i18n }}"
|
||||
label="{{ 'next' | i18n }}"
|
||||
></button>
|
||||
</div>
|
||||
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
|
||||
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 { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components";
|
||||
import { BitIconButtonComponent, MenuItemComponent } from "@bitwarden/components";
|
||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
import { CopyCipherFieldDirective } from "./copy-cipher-field.directive";
|
||||
@@ -83,7 +83,7 @@ describe("CopyCipherFieldDirective", () => {
|
||||
});
|
||||
|
||||
it("updates menuItemDirective disabled state", async () => {
|
||||
const menuItemDirective = {
|
||||
const menuItemComponent = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
@@ -91,14 +91,14 @@ describe("CopyCipherFieldDirective", () => {
|
||||
copyFieldService as unknown as CopyCipherFieldService,
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
menuItemDirective as unknown as MenuItemDirective,
|
||||
menuItemComponent as unknown as MenuItemComponent,
|
||||
);
|
||||
|
||||
copyCipherFieldDirective.action = "totp";
|
||||
|
||||
await copyCipherFieldDirective.ngOnChanges();
|
||||
|
||||
expect(menuItemDirective.disabled).toBe(true);
|
||||
expect(menuItemComponent.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
|
||||
import { MenuItemComponent, BitIconButtonComponent } from "@bitwarden/components";
|
||||
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
alias: "appCopyField",
|
||||
required: true,
|
||||
})
|
||||
action!: Exclude<CopyAction, "hiddenField">;
|
||||
action!: CopyAction;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@@ -47,7 +47,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
private copyCipherFieldService: CopyCipherFieldService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
@Optional() private menuItemDirective?: MenuItemDirective,
|
||||
@Optional() private menuItemComponent?: MenuItemComponent,
|
||||
@Optional() private iconButtonComponent?: BitIconButtonComponent,
|
||||
) {}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
*/
|
||||
@HostBinding("class.tw-hidden")
|
||||
private get hidden() {
|
||||
return this.disabled && this.menuItemDirective;
|
||||
return this.disabled && this.menuItemComponent;
|
||||
}
|
||||
|
||||
@HostListener("click")
|
||||
@@ -87,8 +87,8 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
}
|
||||
|
||||
// If the directive is used on a menu item, update the menu item to prevent keyboard navigation
|
||||
if (this.menuItemDirective) {
|
||||
this.menuItemDirective.disabled = this.disabled ?? false;
|
||||
if (this.menuItemComponent) {
|
||||
this.menuItemComponent.disabled = this.disabled ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DialogModule,
|
||||
DialogService,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type DecryptionFailureDialogParams = {
|
||||
@@ -56,6 +57,9 @@ export class DecryptionFailureDialogComponent {
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, params: DecryptionFailureDialogParams) {
|
||||
return dialogService.open(DecryptionFailureDialogComponent, { data: params });
|
||||
return dialogService.open(DecryptionFailureDialogComponent, {
|
||||
data: params,
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<button
|
||||
*ngIf="!isDecryptionFailure"
|
||||
[bitAction]="download"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[label]="'downloadAttachmentName' | i18n: attachment.fileName"
|
||||
></button>
|
||||
@if (!isDecryptionFailure()) {
|
||||
<button
|
||||
[bitAction]="download"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[label]="'downloadAttachmentName' | i18n: attachment().fileName"
|
||||
></button>
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ describe("DownloadAttachmentComponent", () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DownloadAttachmentComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.attachment = attachment;
|
||||
component.cipher = cipherView;
|
||||
fixture.componentRef.setInput("attachment", attachment);
|
||||
fixture.componentRef.setInput("cipher", cipherView);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -123,7 +123,8 @@ describe("DownloadAttachmentComponent", () => {
|
||||
});
|
||||
|
||||
it("hides download button when the attachment has decryption failure", () => {
|
||||
component.attachment.fileName = DECRYPT_ERROR;
|
||||
const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR };
|
||||
fixture.componentRef.setInput("attachment", decryptFailureAttachment);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css("button"))).toBeNull();
|
||||
@@ -156,7 +157,6 @@ describe("DownloadAttachmentComponent", () => {
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "errorOccurred",
|
||||
title: null,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
@@ -172,7 +172,6 @@ describe("DownloadAttachmentComponent", () => {
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "errorOccurred",
|
||||
title: null,
|
||||
variant: "error",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -17,38 +15,27 @@ import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.v
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-download-attachment",
|
||||
templateUrl: "./download-attachment.component.html",
|
||||
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadAttachmentComponent {
|
||||
/** Attachment to download */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) attachment: AttachmentView;
|
||||
readonly attachment = input.required<AttachmentView>();
|
||||
|
||||
/** The cipher associated with the attachment */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) cipher: CipherView;
|
||||
readonly cipher = input.required<CipherView>();
|
||||
|
||||
// When in view mode, we will want to check for the master password reprompt
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() checkPwReprompt?: boolean = false;
|
||||
/** When in view mode, we will want to check for the master password reprompt */
|
||||
readonly checkPwReprompt = input<boolean>(false);
|
||||
|
||||
// Required for fetching attachment data when viewed from cipher via emergency access
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() emergencyAccessId?: EmergencyAccessId;
|
||||
/** Required for fetching attachment data when viewed from cipher via emergency access */
|
||||
readonly emergencyAccessId = input<EmergencyAccessId>();
|
||||
|
||||
/** When owners/admins can mange all items and when accessing from the admin console, use the admin endpoint */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() admin?: boolean = false;
|
||||
/** When owners/admins can manage all items and when accessing from the admin console, use the admin endpoint */
|
||||
readonly admin = input<boolean>(false);
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
@@ -59,26 +46,36 @@ export class DownloadAttachmentComponent {
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
protected get isDecryptionFailure(): boolean {
|
||||
return this.attachment.fileName === DECRYPT_ERROR;
|
||||
}
|
||||
protected readonly isDecryptionFailure = computed(
|
||||
() => this.attachment().fileName === DECRYPT_ERROR,
|
||||
);
|
||||
|
||||
/** Download the attachment */
|
||||
download = async () => {
|
||||
let url: string;
|
||||
const attachment = this.attachment();
|
||||
const cipher = this.cipher();
|
||||
let url: string | undefined;
|
||||
|
||||
if (!attachment.id) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attachmentDownloadResponse = this.admin
|
||||
? await this.apiService.getAttachmentDataAdmin(this.cipher.id, this.attachment.id)
|
||||
const attachmentDownloadResponse = this.admin()
|
||||
? await this.apiService.getAttachmentDataAdmin(cipher.id, attachment.id)
|
||||
: await this.apiService.getAttachmentData(
|
||||
this.cipher.id,
|
||||
this.attachment.id,
|
||||
this.emergencyAccessId,
|
||||
cipher.id,
|
||||
attachment.id,
|
||||
this.emergencyAccessId(),
|
||||
);
|
||||
url = attachmentDownloadResponse.url;
|
||||
} catch (e) {
|
||||
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
|
||||
url = this.attachment.url;
|
||||
url = attachment.url;
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
@@ -86,11 +83,18 @@ export class DownloadAttachmentComponent {
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(new Request(url, { cache: "no-store" }));
|
||||
if (response.status !== 200) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
@@ -99,26 +103,31 @@ export class DownloadAttachmentComponent {
|
||||
try {
|
||||
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
|
||||
if (!userId || !attachment.fileName) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
|
||||
this.cipher.id as CipherId,
|
||||
this.attachment,
|
||||
cipher.id as CipherId,
|
||||
attachment,
|
||||
response,
|
||||
userId,
|
||||
// When the emergency access ID is present, the cipher is being viewed via emergency access.
|
||||
// Force legacy decryption in these cases.
|
||||
this.emergencyAccessId ? true : false,
|
||||
Boolean(this.emergencyAccessId()),
|
||||
);
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: this.attachment.fileName,
|
||||
fileName: attachment.fileName,
|
||||
blobData: decBuf,
|
||||
});
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
|
||||
13
libs/vault/src/components/vault-items-transfer/index.ts
Normal file
13
libs/vault/src/components/vault-items-transfer/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
TransferItemsDialogComponent,
|
||||
TransferItemsDialogParams,
|
||||
TransferItemsDialogResult,
|
||||
TransferItemsDialogResultType,
|
||||
} from "./transfer-items-dialog.component";
|
||||
|
||||
export {
|
||||
LeaveConfirmationDialogComponent,
|
||||
LeaveConfirmationDialogParams,
|
||||
LeaveConfirmationDialogResult,
|
||||
LeaveConfirmationDialogResultType,
|
||||
} from "./leave-confirmation-dialog.component";
|
||||
@@ -0,0 +1,33 @@
|
||||
<bit-simple-dialog>
|
||||
<i
|
||||
bitDialogIcon
|
||||
class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
|
||||
<span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span>
|
||||
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">
|
||||
{{ "leaveConfirmationDialogContentOne" | i18n }}
|
||||
</p>
|
||||
<p bitTypography="body1" class="tw-mb-0">
|
||||
{{ "leaveConfirmationDialogContentTwo" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="button" buttonType="dangerPrimary" (click)="confirmLeave()">
|
||||
{{ "leaveConfirmationDialogConfirmButton" | i18n: organizationName }}
|
||||
</button>
|
||||
|
||||
<button bitButton type="button" buttonType="secondary" (click)="goBack()">
|
||||
{{ "goBack" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
{{ "howToManageMyVault" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface LeaveConfirmationDialogParams {
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
export const LeaveConfirmationDialogResult = Object.freeze({
|
||||
/**
|
||||
* User confirmed they want to leave the organization.
|
||||
*/
|
||||
Confirmed: "confirmed",
|
||||
/**
|
||||
* User chose to go back instead of leaving the organization.
|
||||
*/
|
||||
Back: "back",
|
||||
} as const);
|
||||
|
||||
export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfirmationDialogResult>;
|
||||
|
||||
@Component({
|
||||
templateUrl: "./leave-confirmation-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
|
||||
})
|
||||
export class LeaveConfirmationDialogComponent {
|
||||
private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA);
|
||||
private readonly dialogRef = inject(DialogRef<LeaveConfirmationDialogResultType>);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
|
||||
protected readonly organizationName = this.params.organizationName;
|
||||
|
||||
protected confirmLeave() {
|
||||
this.dialogRef.close(LeaveConfirmationDialogResult.Confirmed);
|
||||
}
|
||||
|
||||
protected goBack() {
|
||||
this.dialogRef.close(LeaveConfirmationDialogResult.Back);
|
||||
}
|
||||
|
||||
protected openLearnMore(e: Event) {
|
||||
e.preventDefault();
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/");
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<LeaveConfirmationDialogParams>) {
|
||||
return dialogService.open<LeaveConfirmationDialogResultType>(LeaveConfirmationDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
disableClose: true,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<bit-simple-dialog hideIcon>
|
||||
<span bitDialogTitle>{{ "transferItemsToOrganizationTitle" | i18n: organizationName }}</span>
|
||||
|
||||
<span bitDialogContent>
|
||||
{{ "transferItemsToOrganizationContent" | i18n: organizationName }}
|
||||
</span>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton type="button" buttonType="primary" (click)="acceptTransfer()">
|
||||
{{ "acceptTransfer" | i18n }}
|
||||
</button>
|
||||
|
||||
<button bitButton type="button" buttonType="secondary" (click)="decline()">
|
||||
{{ "declineAndLeave" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
{{ "whyAmISeeingThis" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface TransferItemsDialogParams {
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
export const TransferItemsDialogResult = Object.freeze({
|
||||
/**
|
||||
* User accepted the transfer of items.
|
||||
*/
|
||||
Accepted: "accepted",
|
||||
/**
|
||||
* User declined the transfer of items.
|
||||
*/
|
||||
Declined: "declined",
|
||||
} as const);
|
||||
|
||||
export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDialogResult>;
|
||||
|
||||
@Component({
|
||||
templateUrl: "./transfer-items-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
|
||||
})
|
||||
export class TransferItemsDialogComponent {
|
||||
private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA);
|
||||
private readonly dialogRef = inject(DialogRef<TransferItemsDialogResultType>);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
|
||||
protected readonly organizationName = this.params.organizationName;
|
||||
|
||||
protected acceptTransfer() {
|
||||
this.dialogRef.close(TransferItemsDialogResult.Accepted);
|
||||
}
|
||||
|
||||
protected decline() {
|
||||
this.dialogRef.close(TransferItemsDialogResult.Declined);
|
||||
}
|
||||
|
||||
protected openLearnMore(e: Event) {
|
||||
e.preventDefault();
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/");
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<TransferItemsDialogParams>) {
|
||||
return dialogService.open<TransferItemsDialogResultType>(TransferItemsDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
disableClose: true,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { firstValueFrom } from "rxjs";
|
||||
providers: [TextFieldModule],
|
||||
hostDirectives: [CdkTextareaAutosize],
|
||||
})
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/PM-28232): Use Directive suffix
|
||||
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
||||
export class VaultAutosizeReadOnlyTextArea implements AfterViewInit {
|
||||
constructor(
|
||||
@Host() private autosize: CdkTextareaAutosize,
|
||||
|
||||
@@ -3,7 +3,11 @@ export {
|
||||
AtRiskPasswordCalloutData,
|
||||
} from "./services/at-risk-password-callout.service";
|
||||
export { PasswordRepromptService } from "./services/password-reprompt.service";
|
||||
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
|
||||
export {
|
||||
CopyCipherFieldService,
|
||||
CopyAction,
|
||||
CopyFieldAction,
|
||||
} from "./services/copy-cipher-field.service";
|
||||
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
||||
export { OrgIconDirective } from "./components/org-icon.directive";
|
||||
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
||||
@@ -25,10 +29,24 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
|
||||
export * from "./components/carousel";
|
||||
export * from "./components/new-cipher-menu/new-cipher-menu.component";
|
||||
export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component";
|
||||
export * from "./components/vault-items-transfer";
|
||||
|
||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./abstractions/vault-items-transfer.service";
|
||||
export * from "./services/default-vault-items-transfer.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
export * from "./services/archive-cipher-utilities.service";
|
||||
|
||||
export * from "./models/vault-filter.type";
|
||||
export * from "./models/vault-filter.model";
|
||||
export * from "./models/routed-vault-filter.model";
|
||||
export * from "./models/routed-vault-filter-bridge.model";
|
||||
export * from "./models/vault-filter-section.type";
|
||||
export * from "./models/filter-function";
|
||||
export { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service";
|
||||
export * from "./services/vault-filter.service";
|
||||
export * from "./services/routed-vault-filter.service";
|
||||
export * from "./services/routed-vault-filter-bridge.service";
|
||||
|
||||
231
libs/vault/src/models/filter-function.spec.ts
Normal file
231
libs/vault/src/models/filter-function.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { createFilterFunction } from "./filter-function";
|
||||
import { All } from "./routed-vault-filter.model";
|
||||
|
||||
describe("createFilter", () => {
|
||||
describe("given a generic cipher", () => {
|
||||
it("should return true when no filter is applied", () => {
|
||||
const cipher = createCipher();
|
||||
const filterFunction = createFilterFunction({});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a favorite cipher", () => {
|
||||
const cipher = createCipher({ favorite: true });
|
||||
|
||||
it("should return true when filtering for favorites", () => {
|
||||
const filterFunction = createFilterFunction({ type: "favorites" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filtering for trash", () => {
|
||||
const filterFunction = createFilterFunction({ type: "trash" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a deleted cipher", () => {
|
||||
const cipher = createCipher({ deletedDate: new Date() });
|
||||
|
||||
it("should return true when filtering for trash", () => {
|
||||
const filterFunction = createFilterFunction({ type: "trash" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filtering for favorites", () => {
|
||||
const filterFunction = createFilterFunction({ type: "favorites" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when type is not specified in filter", () => {
|
||||
const filterFunction = createFilterFunction({});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher with type", () => {
|
||||
it("should return true when filter matches cipher type", () => {
|
||||
const cipher = createCipher({ type: CipherType.Identity });
|
||||
const filterFunction = createFilterFunction({ type: "identity" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match cipher type", () => {
|
||||
const cipher = createCipher({ type: CipherType.Card });
|
||||
const filterFunction = createFilterFunction({ type: "favorites" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher with folder id", () => {
|
||||
it("should return true when filter matches folder id", () => {
|
||||
const cipher = createCipher({ folderId: "folderId" });
|
||||
const filterFunction = createFilterFunction({ folderId: "folderId" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match folder id", () => {
|
||||
const cipher = createCipher({ folderId: "folderId" });
|
||||
const filterFunction = createFilterFunction({ folderId: "differentFolderId" });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher without folder", () => {
|
||||
const cipher = createCipher({ folderId: null });
|
||||
|
||||
it("should return true when filtering on unassigned folder", () => {
|
||||
const filterFunction = createFilterFunction({ folderId: Unassigned });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an organizational cipher (with organization and collections)", () => {
|
||||
const cipher = createCipher({
|
||||
organizationId: "organizationId",
|
||||
collectionIds: ["collectionId", "anotherId"],
|
||||
});
|
||||
|
||||
it("should return true when filter matches collection id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
collectionId: "collectionId" as CollectionId,
|
||||
organizationId: "organizationId" as OrganizationId,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match collection id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
collectionId: "nonMatchingCollectionId" as CollectionId,
|
||||
organizationId: "organizationId" as OrganizationId,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match organization id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
organizationId: "nonMatchingOrganizationId" as OrganizationId,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filtering for my vault only", () => {
|
||||
const filterFunction = createFilterFunction({ organizationId: Unassigned });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filtering by All Collections", () => {
|
||||
const filterFunction = createFilterFunction({ collectionId: All });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an unassigned organizational cipher (with organization, without collection)", () => {
|
||||
const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] });
|
||||
|
||||
it("should return true when filtering for unassigned collection", () => {
|
||||
const filterFunction = createFilterFunction({ collectionId: Unassigned });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when filter matches organization id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
organizationId: "organizationId" as OrganizationId,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an individual cipher (without organization or collection)", () => {
|
||||
const cipher = createCipher({ organizationId: null, collectionIds: [] });
|
||||
|
||||
it("should return false when filtering for unassigned collection", () => {
|
||||
const filterFunction = createFilterFunction({ collectionId: Unassigned });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when filtering for my vault only", () => {
|
||||
const cipher = createCipher({ organizationId: null });
|
||||
const filterFunction = createFilterFunction({ organizationId: Unassigned });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createCipher(options: Partial<CipherView> = {}) {
|
||||
const cipher = new CipherView();
|
||||
|
||||
cipher.favorite = options.favorite ?? false;
|
||||
cipher.deletedDate = options.deletedDate;
|
||||
cipher.type = options.type ?? CipherType.Login;
|
||||
cipher.folderId = options.folderId;
|
||||
cipher.collectionIds = options.collectionIds;
|
||||
cipher.organizationId = options.organizationId;
|
||||
|
||||
return cipher;
|
||||
}
|
||||
107
libs/vault/src/models/filter-function.ts
Normal file
107
libs/vault/src/models/filter-function.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model";
|
||||
|
||||
export type FilterFunction = (cipher: CipherViewLike) => boolean;
|
||||
|
||||
export function createFilterFunction(
|
||||
filter: RoutedVaultFilterModel,
|
||||
archiveEnabled?: boolean,
|
||||
): FilterFunction {
|
||||
return (cipher) => {
|
||||
const type = CipherViewLikeUtils.getType(cipher);
|
||||
const isDeleted = CipherViewLikeUtils.isDeleted(cipher);
|
||||
|
||||
if (filter.type === "favorites" && !cipher.favorite) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "card" && type !== CipherType.Card) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "identity" && type !== CipherType.Identity) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "login" && type !== CipherType.Login) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "note" && type !== CipherType.SecureNote) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "sshKey" && type !== CipherType.SshKey) {
|
||||
return false;
|
||||
}
|
||||
if (filter.type === "trash" && !isDeleted) {
|
||||
return false;
|
||||
}
|
||||
// Hide trash unless explicitly selected
|
||||
if (filter.type !== "trash" && isDeleted) {
|
||||
return false;
|
||||
}
|
||||
// Archive filter logic is only applied if the feature flag is enabled
|
||||
if (archiveEnabled) {
|
||||
if (filter.type === "archive" && !CipherViewLikeUtils.isArchived(cipher)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filter.type !== "archive" &&
|
||||
filter.type !== "trash" &&
|
||||
CipherViewLikeUtils.isArchived(cipher)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// No folder
|
||||
if (filter.folderId === Unassigned && cipher.folderId != null) {
|
||||
return false;
|
||||
}
|
||||
// Folder
|
||||
if (
|
||||
filter.folderId !== undefined &&
|
||||
filter.folderId !== All &&
|
||||
filter.folderId !== Unassigned &&
|
||||
cipher.folderId !== filter.folderId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// All collections (top level)
|
||||
if (filter.collectionId === All) {
|
||||
return false;
|
||||
}
|
||||
// Unassigned
|
||||
if (
|
||||
filter.collectionId === Unassigned &&
|
||||
(cipher.organizationId == null ||
|
||||
(cipher.collectionIds != null && cipher.collectionIds.length > 0))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Collection
|
||||
if (
|
||||
filter.collectionId !== undefined &&
|
||||
filter.collectionId !== All &&
|
||||
filter.collectionId !== Unassigned &&
|
||||
(cipher.collectionIds == null || !cipher.collectionIds.includes(filter.collectionId as any))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// My Vault
|
||||
if (filter.organizationId === Unassigned && cipher.organizationId != null) {
|
||||
return false;
|
||||
}
|
||||
// Organization
|
||||
else if (
|
||||
filter.organizationId !== undefined &&
|
||||
filter.organizationId !== Unassigned &&
|
||||
cipher.organizationId !== filter.organizationId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
172
libs/vault/src/models/routed-vault-filter-bridge.model.ts
Normal file
172
libs/vault/src/models/routed-vault-filter-bridge.model.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { RoutedVaultFilterBridgeService } from "../services/routed-vault-filter-bridge.service";
|
||||
|
||||
import {
|
||||
All,
|
||||
isRoutedVaultFilterItemType,
|
||||
RoutedVaultFilterItemType,
|
||||
RoutedVaultFilterModel,
|
||||
} from "./routed-vault-filter.model";
|
||||
import { VaultFilter, VaultFilterFunction } from "./vault-filter.model";
|
||||
import {
|
||||
OrganizationFilter,
|
||||
CipherTypeFilter,
|
||||
FolderFilter,
|
||||
CollectionFilter,
|
||||
CipherStatus,
|
||||
} from "./vault-filter.type";
|
||||
|
||||
/**
|
||||
* This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method.
|
||||
* This should be removed after we have refactored the {@link VaultItemsComponent} and introduced vertical navigation
|
||||
* (which will refactor the {@link VaultFilterComponent}).
|
||||
*
|
||||
* This model supplies legacy code with the old state-in-code models saved as tree nodes.
|
||||
* It can also receive requests to select a new tree node by using setters.
|
||||
* However instead of just replacing the tree node models, it requests a URL navigation,
|
||||
* thereby bridging between legacy and URL filtering.
|
||||
*/
|
||||
export class RoutedVaultFilterBridge implements VaultFilter {
|
||||
constructor(
|
||||
private routedFilter: RoutedVaultFilterModel,
|
||||
private legacyFilter: VaultFilter,
|
||||
private bridgeService: RoutedVaultFilterBridgeService,
|
||||
) {}
|
||||
get collectionBreadcrumbs(): TreeNode<CollectionFilter>[] {
|
||||
return this.legacyFilter.collectionBreadcrumbs;
|
||||
}
|
||||
get isCollectionSelected(): boolean {
|
||||
return this.legacyFilter.isCollectionSelected;
|
||||
}
|
||||
get isUnassignedCollectionSelected(): boolean {
|
||||
return this.legacyFilter.isUnassignedCollectionSelected;
|
||||
}
|
||||
get isMyVaultSelected(): boolean {
|
||||
return this.legacyFilter.isMyVaultSelected;
|
||||
}
|
||||
get selectedOrganizationNode(): TreeNode<OrganizationFilter> {
|
||||
return this.legacyFilter.selectedOrganizationNode;
|
||||
}
|
||||
set selectedOrganizationNode(value: TreeNode<OrganizationFilter>) {
|
||||
this.bridgeService.navigate({
|
||||
...this.routedFilter,
|
||||
organizationId: value?.node.id === "MyVault" ? Unassigned : value?.node.id,
|
||||
folderId: undefined,
|
||||
collectionId: undefined,
|
||||
});
|
||||
}
|
||||
get selectedCipherTypeNode(): TreeNode<CipherTypeFilter> {
|
||||
return this.legacyFilter.selectedCipherTypeNode;
|
||||
}
|
||||
set selectedCipherTypeNode(value: TreeNode<CipherTypeFilter>) {
|
||||
let type: RoutedVaultFilterItemType | undefined;
|
||||
|
||||
if (value?.node.id === "AllItems" && this.routedFilter.organizationIdParamType === "path") {
|
||||
type = All;
|
||||
} else if (
|
||||
value?.node.id === "AllItems" &&
|
||||
this.routedFilter.organizationIdParamType === "query"
|
||||
) {
|
||||
type = undefined;
|
||||
} else if (isRoutedVaultFilterItemType(value?.node.id)) {
|
||||
type = value?.node.id;
|
||||
}
|
||||
|
||||
this.bridgeService.navigate({
|
||||
...this.routedFilter,
|
||||
type,
|
||||
folderId: undefined,
|
||||
collectionId: undefined,
|
||||
});
|
||||
}
|
||||
get selectedFolderNode(): TreeNode<FolderFilter> {
|
||||
return this.legacyFilter.selectedFolderNode;
|
||||
}
|
||||
set selectedFolderNode(value: TreeNode<FolderFilter>) {
|
||||
const folderId = value?.node.id ?? Unassigned;
|
||||
this.bridgeService.navigate({
|
||||
...this.routedFilter,
|
||||
folderId,
|
||||
type: undefined,
|
||||
collectionId: undefined,
|
||||
});
|
||||
}
|
||||
get selectedCollectionNode(): TreeNode<CollectionFilter> {
|
||||
return this.legacyFilter.selectedCollectionNode;
|
||||
}
|
||||
set selectedCollectionNode(value: TreeNode<CollectionFilter>) {
|
||||
let collectionId: CollectionId | All | Unassigned | undefined;
|
||||
|
||||
if (value != null && value.node.id === null) {
|
||||
collectionId = Unassigned;
|
||||
} else if (
|
||||
value?.node.id === "AllCollections" &&
|
||||
this.routedFilter.organizationIdParamType === "path"
|
||||
) {
|
||||
collectionId = undefined;
|
||||
} else if (
|
||||
value?.node.id === "AllCollections" &&
|
||||
this.routedFilter.organizationIdParamType === "query"
|
||||
) {
|
||||
collectionId = All;
|
||||
} else {
|
||||
collectionId = value?.node.id;
|
||||
}
|
||||
|
||||
this.bridgeService.navigate({
|
||||
...this.routedFilter,
|
||||
collectionId,
|
||||
type: undefined,
|
||||
folderId: undefined,
|
||||
});
|
||||
}
|
||||
get isFavorites(): boolean {
|
||||
return this.legacyFilter.isFavorites;
|
||||
}
|
||||
get isDeleted(): boolean {
|
||||
return this.legacyFilter.isDeleted;
|
||||
}
|
||||
get isArchived(): boolean {
|
||||
return this.legacyFilter.isArchived;
|
||||
}
|
||||
get organizationId(): string {
|
||||
return this.legacyFilter.organizationId;
|
||||
}
|
||||
get cipherType(): CipherType {
|
||||
return this.legacyFilter.cipherType;
|
||||
}
|
||||
get cipherStatus(): CipherStatus {
|
||||
return this.legacyFilter.cipherStatus;
|
||||
}
|
||||
get cipherTypeId(): string {
|
||||
return this.legacyFilter.cipherTypeId;
|
||||
}
|
||||
get folderId(): string {
|
||||
return this.legacyFilter.folderId;
|
||||
}
|
||||
get collectionId(): string {
|
||||
return this.legacyFilter.collectionId;
|
||||
}
|
||||
resetFilter(): void {
|
||||
this.bridgeService.navigate({
|
||||
...this.routedFilter,
|
||||
collectionId: undefined,
|
||||
folderId: undefined,
|
||||
organizationId:
|
||||
this.routedFilter.organizationIdParamType === "path"
|
||||
? this.routedFilter.organizationId
|
||||
: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
}
|
||||
resetOrganization(): void {
|
||||
this.bridgeService.navigate({ ...this.routedFilter, organizationId: undefined });
|
||||
}
|
||||
buildFilter(): VaultFilterFunction {
|
||||
return this.legacyFilter.buildFilter();
|
||||
}
|
||||
}
|
||||
36
libs/vault/src/models/routed-vault-filter.model.ts
Normal file
36
libs/vault/src/models/routed-vault-filter.model.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* A constant used to represent viewing "all" of a particular filter.
|
||||
*/
|
||||
export const All = "all";
|
||||
export type All = typeof All;
|
||||
|
||||
// TODO: Remove `All` when moving to vertical navigation.
|
||||
const itemTypes = [
|
||||
"favorites",
|
||||
"login",
|
||||
"card",
|
||||
"identity",
|
||||
"note",
|
||||
"sshKey",
|
||||
"archive",
|
||||
"trash",
|
||||
All,
|
||||
] as const;
|
||||
|
||||
export type RoutedVaultFilterItemType = (typeof itemTypes)[number];
|
||||
|
||||
export function isRoutedVaultFilterItemType(value: unknown): value is RoutedVaultFilterItemType {
|
||||
return itemTypes.includes(value as any);
|
||||
}
|
||||
|
||||
export interface RoutedVaultFilterModel {
|
||||
collectionId?: CollectionId | All | Unassigned;
|
||||
folderId?: string;
|
||||
organizationId?: OrganizationId | Unassigned;
|
||||
type?: RoutedVaultFilterItemType;
|
||||
|
||||
organizationIdParamType?: "path" | "query";
|
||||
}
|
||||
64
libs/vault/src/models/vault-filter-section.type.ts
Normal file
64
libs/vault/src/models/vault-filter-section.type.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
|
||||
import {
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "./vault-filter.type";
|
||||
|
||||
export type VaultFilterType =
|
||||
| OrganizationFilter
|
||||
| CipherTypeFilter
|
||||
| FolderFilter
|
||||
| CollectionFilter;
|
||||
|
||||
export const VaultFilterLabel = {
|
||||
OrganizationFilter: "organizationFilter",
|
||||
TypeFilter: "typeFilter",
|
||||
FolderFilter: "folderFilter",
|
||||
CollectionFilter: "collectionFilter",
|
||||
ArchiveFilter: "archiveFilter",
|
||||
TrashFilter: "trashFilter",
|
||||
} as const;
|
||||
|
||||
type VaultFilterLabel = UnionOfValues<typeof VaultFilterLabel>;
|
||||
|
||||
export type VaultFilterSection = {
|
||||
data$: Observable<TreeNode<VaultFilterType>>;
|
||||
header: {
|
||||
showHeader: boolean;
|
||||
isSelectable: boolean;
|
||||
};
|
||||
action: (filterNode: TreeNode<VaultFilterType>) => Promise<void>;
|
||||
edit?: {
|
||||
filterName: string;
|
||||
action: (filter: VaultFilterType) => void;
|
||||
};
|
||||
add?: {
|
||||
text: string;
|
||||
route?: string;
|
||||
action?: () => void;
|
||||
};
|
||||
options?: {
|
||||
component: any;
|
||||
};
|
||||
divider?: boolean;
|
||||
premiumOptions?: {
|
||||
/** When true, the premium badge will show on the filter for non-premium users. */
|
||||
showBadgeForNonPremium?: true;
|
||||
/**
|
||||
* Action to be called instead of applying the filter.
|
||||
* Useful when the user does not have access to a filter (e.g., premium feature)
|
||||
* and custom behavior is needed when invoking the filter.
|
||||
*/
|
||||
blockFilterAction?: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type VaultFilterList = {
|
||||
[key in VaultFilterLabel]?: VaultFilterSection;
|
||||
};
|
||||
338
libs/vault/src/models/vault-filter.model.spec.ts
Normal file
338
libs/vault/src/models/vault-filter.model.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { VaultFilter } from "./vault-filter.model";
|
||||
import {
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "./vault-filter.type";
|
||||
|
||||
describe("VaultFilter", () => {
|
||||
describe("filterFunction", () => {
|
||||
const allCiphersFilter = new TreeNode<CipherTypeFilter>(
|
||||
{
|
||||
id: "AllItems",
|
||||
name: "allItems",
|
||||
type: "all",
|
||||
icon: "",
|
||||
},
|
||||
null,
|
||||
);
|
||||
const favoriteCiphersFilter = new TreeNode<CipherTypeFilter>(
|
||||
{
|
||||
id: "favorites",
|
||||
name: "favorites",
|
||||
type: "favorites",
|
||||
icon: "bwi-star",
|
||||
},
|
||||
null,
|
||||
);
|
||||
const identityCiphersFilter = new TreeNode<CipherTypeFilter>(
|
||||
{
|
||||
id: "identity",
|
||||
name: "identity",
|
||||
type: CipherType.Identity,
|
||||
icon: "bwi-id-card",
|
||||
},
|
||||
null,
|
||||
);
|
||||
const trashFilter = new TreeNode<CipherTypeFilter>(
|
||||
{
|
||||
id: "trash",
|
||||
name: "trash",
|
||||
type: "trash",
|
||||
icon: "bwi-trash",
|
||||
},
|
||||
null,
|
||||
);
|
||||
describe("generic cipher", () => {
|
||||
it("should return true when no filter is applied", () => {
|
||||
const cipher = createCipher();
|
||||
const filterFunction = createFilterFunction({});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a favorite cipher", () => {
|
||||
const cipher = createCipher({ favorite: true });
|
||||
|
||||
it("should return true when filtering for favorites", () => {
|
||||
const filterFunction = createFilterFunction({ selectedCipherTypeNode: allCiphersFilter });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filtering for trash", () => {
|
||||
const filterFunction = createFilterFunction({ selectedCipherTypeNode: trashFilter });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a deleted cipher", () => {
|
||||
const cipher = createCipher({ deletedDate: new Date() });
|
||||
|
||||
it("should return true when filtering for trash", () => {
|
||||
const filterFunction = createFilterFunction({ selectedCipherTypeNode: trashFilter });
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filtering for favorites", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCipherTypeNode: favoriteCiphersFilter,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher with type", () => {
|
||||
it("should return true when filter matches cipher type", () => {
|
||||
const cipher = createCipher({ type: CipherType.Identity });
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCipherTypeNode: identityCiphersFilter,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match cipher type", () => {
|
||||
const cipher = createCipher({ type: CipherType.Card });
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCipherTypeNode: identityCiphersFilter,
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher with folder id", () => {
|
||||
it("should return true when filter matches folder id", () => {
|
||||
const cipher = createCipher({ folderId: "folderId" });
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedFolderNode: createFolderFilterNode({ id: "folderId" }),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match folder id", () => {
|
||||
const cipher = createCipher({ folderId: "folderId" });
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedFolderNode: createFolderFilterNode({ id: "differentFolderId" }),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a cipher without folder", () => {
|
||||
const cipher = createCipher({ folderId: null });
|
||||
|
||||
it("should return true when filtering on unassigned folder", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedFolderNode: createFolderFilterNode({ id: null }),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an organizational cipher (with organization and collections)", () => {
|
||||
const cipher = createCipher({
|
||||
organizationId: "organizationId",
|
||||
collectionIds: ["collectionId", "anotherId"],
|
||||
});
|
||||
|
||||
it("should return true when filter matches collection id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCollectionNode: createCollectionFilterNode({
|
||||
id: "collectionId" as CollectionId,
|
||||
organizationId: "organizationId" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match collection id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCollectionNode: createCollectionFilterNode({
|
||||
id: "nonMatchingCollectionId" as CollectionId,
|
||||
organizationId: "organizationId" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filter does not match organization id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedOrganizationNode: createOrganizationFilterNode({
|
||||
id: "nonMatchingOrganizationId" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filtering for my vault only", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedOrganizationNode: createOrganizationFilterNode({
|
||||
id: "MyVault" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when filtering by All Collections", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCollectionNode: createCollectionFilterNode({
|
||||
id: "AllCollections" as CollectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an unassigned organizational cipher (with organization, without collection)", () => {
|
||||
const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] });
|
||||
|
||||
it("should return true when filtering for unassigned collection", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCollectionNode: createCollectionFilterNode({ id: null }),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when filter matches organization id", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedOrganizationNode: createOrganizationFilterNode({
|
||||
id: "organizationId" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given an individual cipher (without organization or collection)", () => {
|
||||
const cipher = createCipher({ organizationId: null, collectionIds: [] });
|
||||
|
||||
it("should return false when filtering for unassigned collection", () => {
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedCollectionNode: createCollectionFilterNode({ id: null }),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when filtering for my vault only", () => {
|
||||
const cipher = createCipher({ organizationId: null });
|
||||
const filterFunction = createFilterFunction({
|
||||
selectedOrganizationNode: createOrganizationFilterNode({
|
||||
id: "MyVault" as OrganizationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = filterFunction(cipher);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createFilterFunction(options: Partial<VaultFilter> = {}) {
|
||||
return new VaultFilter(options).buildFilter();
|
||||
}
|
||||
|
||||
function createOrganizationFilterNode(
|
||||
options: Partial<OrganizationFilter>,
|
||||
): TreeNode<OrganizationFilter> {
|
||||
const org = new Organization() as OrganizationFilter;
|
||||
org.id = options.id;
|
||||
org.icon = options.icon ?? "";
|
||||
return new TreeNode<OrganizationFilter>(org, null);
|
||||
}
|
||||
|
||||
function createFolderFilterNode(options: Partial<FolderFilter>): TreeNode<FolderFilter> {
|
||||
const folder = new FolderView() as FolderFilter;
|
||||
folder.id = options.id;
|
||||
folder.name = options.name;
|
||||
folder.icon = options.icon ?? "";
|
||||
folder.revisionDate = options.revisionDate ?? new Date();
|
||||
return new TreeNode<FolderFilter>(folder, null);
|
||||
}
|
||||
|
||||
function createCollectionFilterNode(
|
||||
options: Partial<CollectionFilter>,
|
||||
): TreeNode<CollectionFilter> {
|
||||
const collection = new CollectionView({
|
||||
name: options.name ?? "Test Name",
|
||||
id: options.id ?? null,
|
||||
organizationId: options.organizationId ?? ("Org Id" as OrganizationId),
|
||||
}) as CollectionFilter;
|
||||
return new TreeNode<CollectionFilter>(collection, {} as TreeNode<CollectionFilter>);
|
||||
}
|
||||
|
||||
function createCipher(options: Partial<CipherView> = {}) {
|
||||
const cipher = new CipherView();
|
||||
|
||||
cipher.favorite = options.favorite ?? false;
|
||||
cipher.deletedDate = options.deletedDate;
|
||||
cipher.type = options.type;
|
||||
cipher.folderId = options.folderId;
|
||||
cipher.collectionIds = options.collectionIds;
|
||||
cipher.organizationId = options.organizationId;
|
||||
|
||||
return cipher;
|
||||
}
|
||||
178
libs/vault/src/models/vault-filter.model.ts
Normal file
178
libs/vault/src/models/vault-filter.model.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherType, isCipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import {
|
||||
CipherStatus,
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "./vault-filter.type";
|
||||
|
||||
export type VaultFilterFunction = (cipher: CipherView) => boolean;
|
||||
|
||||
// TODO: Replace shared VaultFilter Model with this one and
|
||||
// refactor browser and desktop code to use this model.
|
||||
export class VaultFilter {
|
||||
selectedOrganizationNode: TreeNode<OrganizationFilter>;
|
||||
selectedCipherTypeNode: TreeNode<CipherTypeFilter>;
|
||||
selectedFolderNode: TreeNode<FolderFilter>;
|
||||
selectedCollectionNode: TreeNode<CollectionFilter>;
|
||||
|
||||
/**
|
||||
* A list of collection filters that form a chain from the organization root to currently selected collection.
|
||||
* To be used when rendering a breadcrumb UI for navigating the collection hierarchy.
|
||||
* Begins from the organization root and excludes the currently selected collection.
|
||||
*/
|
||||
get collectionBreadcrumbs(): TreeNode<CollectionFilter>[] {
|
||||
if (!this.isCollectionSelected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collections = [this.selectedCollectionNode];
|
||||
while (collections[collections.length - 1].parent != undefined) {
|
||||
collections.push(collections[collections.length - 1].parent);
|
||||
}
|
||||
|
||||
return collections.slice(1).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* The vault is filtered by a specific collection
|
||||
*/
|
||||
get isCollectionSelected(): boolean {
|
||||
return (
|
||||
this.selectedCollectionNode != null &&
|
||||
this.selectedCollectionNode.node.id !== "AllCollections"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The vault is filtered by the "Unassigned" collection
|
||||
*/
|
||||
get isUnassignedCollectionSelected(): boolean {
|
||||
return this.selectedCollectionNode != null && this.selectedCollectionNode.node.id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vault is filtered by the users individual vault
|
||||
*/
|
||||
get isMyVaultSelected(): boolean {
|
||||
return this.selectedOrganizationNode?.node.id === "MyVault";
|
||||
}
|
||||
|
||||
get isFavorites(): boolean {
|
||||
return this.selectedCipherTypeNode?.node.type === "favorites";
|
||||
}
|
||||
|
||||
get isDeleted(): boolean {
|
||||
return this.selectedCipherTypeNode?.node.type === "trash" ? true : null;
|
||||
}
|
||||
|
||||
get isArchived(): boolean {
|
||||
return this.selectedCipherTypeNode?.node.type === "archive";
|
||||
}
|
||||
|
||||
get organizationId(): string {
|
||||
return this.selectedOrganizationNode?.node.id;
|
||||
}
|
||||
|
||||
get cipherType(): CipherType {
|
||||
return isCipherType(this.selectedCipherTypeNode?.node.type)
|
||||
? this.selectedCipherTypeNode?.node.type
|
||||
: null;
|
||||
}
|
||||
|
||||
get cipherStatus(): CipherStatus {
|
||||
return this.selectedCipherTypeNode?.node.type;
|
||||
}
|
||||
|
||||
get cipherTypeId(): string {
|
||||
return this.selectedCipherTypeNode?.node.id;
|
||||
}
|
||||
|
||||
get folderId(): string {
|
||||
return this.selectedFolderNode?.node.id;
|
||||
}
|
||||
|
||||
get collectionId(): string {
|
||||
return this.selectedCollectionNode?.node.id;
|
||||
}
|
||||
|
||||
constructor(init?: Partial<VaultFilter>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
resetFilter() {
|
||||
this.selectedCipherTypeNode = null;
|
||||
this.selectedFolderNode = null;
|
||||
this.selectedCollectionNode = null;
|
||||
}
|
||||
|
||||
resetOrganization() {
|
||||
this.selectedOrganizationNode = null;
|
||||
}
|
||||
|
||||
buildFilter(): VaultFilterFunction {
|
||||
return (cipher) => {
|
||||
let cipherPassesFilter = true;
|
||||
if (this.isFavorites && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.favorite;
|
||||
}
|
||||
if (this.isDeleted && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.isDeleted;
|
||||
}
|
||||
if (this.isArchived && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.isArchived;
|
||||
}
|
||||
if (this.cipherType && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.type === this.cipherType;
|
||||
}
|
||||
if (this.selectedFolderNode) {
|
||||
// No folder
|
||||
if (this.folderId === null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.folderId === null;
|
||||
}
|
||||
// Folder
|
||||
if (this.folderId !== null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.folderId === this.folderId;
|
||||
}
|
||||
}
|
||||
if (this.selectedCollectionNode) {
|
||||
// All Collections
|
||||
if (this.collectionId === "AllCollections" && cipherPassesFilter) {
|
||||
cipherPassesFilter = false;
|
||||
}
|
||||
// Unassigned
|
||||
if (this.collectionId === null && cipherPassesFilter) {
|
||||
cipherPassesFilter =
|
||||
cipher.organizationId != null &&
|
||||
(cipher.collectionIds == null || cipher.collectionIds.length === 0);
|
||||
}
|
||||
// Collection
|
||||
if (
|
||||
this.collectionId !== null &&
|
||||
this.collectionId !== "AllCollections" &&
|
||||
cipherPassesFilter
|
||||
) {
|
||||
cipherPassesFilter =
|
||||
cipher.collectionIds != null && cipher.collectionIds.includes(this.collectionId);
|
||||
}
|
||||
}
|
||||
if (this.selectedOrganizationNode) {
|
||||
// My Vault
|
||||
if (this.organizationId === "MyVault" && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.organizationId === null;
|
||||
}
|
||||
// Organization
|
||||
else if (this.organizationId !== null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.organizationId === this.organizationId;
|
||||
}
|
||||
}
|
||||
return cipherPassesFilter;
|
||||
};
|
||||
}
|
||||
}
|
||||
22
libs/vault/src/models/vault-filter.type.ts
Normal file
22
libs/vault/src/models/vault-filter.type.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
export type CipherStatus = "all" | "favorites" | "archive" | "trash" | CipherType;
|
||||
|
||||
export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: string };
|
||||
export type CollectionFilter = CollectionAdminView & {
|
||||
icon: string;
|
||||
};
|
||||
export type FolderFilter = FolderView & {
|
||||
icon: string;
|
||||
/**
|
||||
* Full folder name.
|
||||
*
|
||||
* Used for when the folder `name` property is be separated into parts.
|
||||
*/
|
||||
fullName?: string;
|
||||
};
|
||||
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };
|
||||
@@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
const mockCipher = new CipherView();
|
||||
mockCipher.id = "cipher-id" as CipherId;
|
||||
const mockUserId = "user-id";
|
||||
const mockCipherData = { id: mockCipher.id } as CipherData;
|
||||
|
||||
beforeEach(() => {
|
||||
cipherArchiveService = mock<CipherArchiveService>();
|
||||
@@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
|
||||
dialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue(undefined);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined);
|
||||
cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData);
|
||||
cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData);
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
service = new ArchiveCipherUtilitiesService(
|
||||
|
||||
@@ -25,16 +25,24 @@ export class ArchiveCipherUtilitiesService {
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
/** Archive a cipher, with confirmation dialog and password reprompt checks. */
|
||||
async archiveCipher(cipher: CipherView) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
/** Archive a cipher, with confirmation dialog and password reprompt checks.
|
||||
*
|
||||
* @param cipher The cipher to archive
|
||||
* @param skipReprompt Whether to skip the password reprompt check
|
||||
* @returns The archived CipherData on success, or undefined on failure or cancellation
|
||||
*/
|
||||
async archiveCipher(cipher: CipherView, skipReprompt = false) {
|
||||
if (!skipReprompt) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
content: { key: "archiveItemDialogContent" },
|
||||
acceptButtonText: { key: "archiveVerb" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
@@ -43,38 +51,47 @@ export class ArchiveCipherUtilitiesService {
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherArchiveService
|
||||
.archiveWithServer(cipher.id as CipherId, userId)
|
||||
.then(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
try {
|
||||
const cipherResponse = await this.cipherArchiveService.archiveWithServer(
|
||||
cipher.id as CipherId,
|
||||
userId,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unarchives a cipher */
|
||||
/** Unarchives a cipher
|
||||
* @param cipher The cipher to unarchive
|
||||
* @returns The unarchived cipher on success, or undefined on failure
|
||||
*/
|
||||
async unarchiveCipher(cipher: CipherView) {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherArchiveService
|
||||
.unarchiveWithServer(cipher.id as CipherId, userId)
|
||||
.then(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
try {
|
||||
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(
|
||||
cipher.id as CipherId,
|
||||
userId,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
});
|
||||
return cipherResponse;
|
||||
} catch {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ export type CopyAction =
|
||||
| "publicKey"
|
||||
| "keyFingerprint";
|
||||
|
||||
/**
|
||||
* Copy actions that can be used with the appCopyField directive.
|
||||
* Excludes "hiddenField" which requires special handling.
|
||||
*/
|
||||
export type CopyFieldAction = Exclude<CopyAction, "hiddenField">;
|
||||
|
||||
type CopyActionInfo = {
|
||||
/**
|
||||
* The i18n key for the type of field being copied. Will be used to display a toast message.
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of, Subject } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
LeaveConfirmationDialogResult,
|
||||
TransferItemsDialogResult,
|
||||
} from "../components/vault-items-transfer";
|
||||
|
||||
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
|
||||
|
||||
describe("DefaultVaultItemsTransferService", () => {
|
||||
let service: DefaultVaultItemsTransferService;
|
||||
let transferInProgressValues: boolean[];
|
||||
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockCollectionService: MockProxy<CollectionService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockEventCollectionService: MockProxy<EventCollectionService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
const collectionId = "collection-id" as CollectionId;
|
||||
|
||||
/**
|
||||
* Creates a mock DialogRef that emits the provided result when closed
|
||||
*/
|
||||
function createMockDialogRef<T>(result: T): DialogRef<T> {
|
||||
const closed$ = new Subject<T>();
|
||||
const dialogRef = {
|
||||
closed: closed$.asObservable(),
|
||||
close: jest.fn(),
|
||||
} as unknown as DialogRef<T>;
|
||||
|
||||
// Emit the result asynchronously to simulate dialog closing
|
||||
setTimeout(() => {
|
||||
closed$.next(result);
|
||||
closed$.complete();
|
||||
}, 0);
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockCollectionService = mock<CollectionService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockEventCollectionService = mock<EventCollectionService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
transferInProgressValues = [];
|
||||
|
||||
service = new DefaultVaultItemsTransferService(
|
||||
mockCipherService,
|
||||
mockPolicyService,
|
||||
mockOrganizationService,
|
||||
mockCollectionService,
|
||||
mockLogService,
|
||||
mockI18nService,
|
||||
mockDialogService,
|
||||
mockToastService,
|
||||
mockEventCollectionService,
|
||||
mockConfigService,
|
||||
mockOrganizationUserApiService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("userMigrationInfo$", () => {
|
||||
// Helper to setup common mock scenario
|
||||
function setupMocksForMigrationScenario(options: {
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("calls policiesByType$ with correct PolicyType", async () => {
|
||||
setupMocksForMigrationScenario({ policies: [] });
|
||||
|
||||
await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith(
|
||||
PolicyType.OrganizationDataOwnership,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
describe("when no policy exists", () => {
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({ policies: [] });
|
||||
});
|
||||
|
||||
it("returns requiresMigration: false", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when policy exists", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
});
|
||||
});
|
||||
|
||||
describe("and user has no personal ciphers", () => {
|
||||
beforeEach(() => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
it("returns requiresMigration: false", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and user has personal ciphers", () => {
|
||||
beforeEach(() => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView]));
|
||||
});
|
||||
|
||||
it("returns requiresMigration: true", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes defaultCollectionId when a default collection exists", async () => {
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(
|
||||
of({
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: collectionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out organization ciphers when checking for personal ciphers", async () => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "cipher-1",
|
||||
organizationId: organizationId as string,
|
||||
} as CipherView,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: false,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when multiple policies exist", () => {
|
||||
const olderPolicy = {
|
||||
organizationId: "older-org-id" as OrganizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const newerPolicy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-06-01"),
|
||||
} as Policy;
|
||||
const olderOrganization = {
|
||||
id: "older-org-id" as OrganizationId,
|
||||
name: "Older Org",
|
||||
} as Organization;
|
||||
const newerOrganization = {
|
||||
id: organizationId,
|
||||
name: "Newer Org",
|
||||
} as Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
setupMocksForMigrationScenario({
|
||||
policies: [newerPolicy, olderPolicy],
|
||||
organizations: [olderOrganization, newerOrganization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the oldest policy when selecting enforcing organization", async () => {
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: olderOrganization,
|
||||
defaultCollectionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferPersonalItems", () => {
|
||||
it("does nothing when user has no personal ciphers", async () => {
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([]));
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
expect(mockLogService.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls shareManyWithServer with correct parameters", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[];
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("transfers only personal ciphers, not organization ciphers", async () => {
|
||||
const allCiphers = [
|
||||
{ id: "cipher-1" },
|
||||
{ id: "cipher-2", organizationId: "other-org-id" },
|
||||
{ id: "cipher-3" },
|
||||
] as CipherView[];
|
||||
|
||||
const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]];
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(allCiphers));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
expectedPersonalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates errors from shareManyWithServer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" }] as CipherView[];
|
||||
|
||||
const error = new Error("Transfer failed");
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Transfer failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upgradeOldAttachments", () => {
|
||||
it("upgrades old attachments before transferring", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: false,
|
||||
attachments: [{ key: "new-key" }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(
|
||||
cipherWithOldAttachment,
|
||||
userId,
|
||||
);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
[upgradedCipher],
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("upgrades multiple ciphers with old attachments", async () => {
|
||||
const cipher1 = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const cipher2 = {
|
||||
id: "cipher-2",
|
||||
name: "Cipher 2",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView;
|
||||
const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipher1, cipher2]))
|
||||
.mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2]));
|
||||
mockCipherService.upgradeOldCipherAttachments
|
||||
.mockResolvedValueOnce(upgradedCipher1)
|
||||
.mockResolvedValueOnce(upgradedCipher2);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2);
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId);
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId);
|
||||
});
|
||||
|
||||
it("skips attachments that already have keys", async () => {
|
||||
const cipherWithMixedAttachments = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: "existing-key" }, { key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithMixedAttachments,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithMixedAttachments]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
// Should only be called once for the attachment without a key
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws error when upgradeOldCipherAttachments fails", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed"));
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when upgrade returns cipher still having old attachments", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
// Upgrade returns but cipher still has old attachments
|
||||
const stillOldCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when sanity check finds remaining old attachments after upgrade", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
// First call returns cipher with old attachment, second call (after upgrade) still returns old attachment
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
|
||||
await expect(
|
||||
service.transferPersonalItems(userId, organizationId, collectionId),
|
||||
).rejects.toThrow(
|
||||
"Failed to upgrade all old attachments. 1 ciphers still have old attachments.",
|
||||
);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs info when upgrading old attachments", async () => {
|
||||
const cipherWithOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: true,
|
||||
attachments: [{ key: null }],
|
||||
} as unknown as CipherView;
|
||||
|
||||
const upgradedCipher = {
|
||||
...cipherWithOldAttachment,
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$
|
||||
.mockReturnValueOnce(of([cipherWithOldAttachment]))
|
||||
.mockReturnValueOnce(of([upgradedCipher]));
|
||||
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"),
|
||||
);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not upgrade when ciphers have no old attachments", async () => {
|
||||
const cipherWithoutOldAttachment = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
hasOldAttachments: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment]));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.transferPersonalItems(userId, organizationId, collectionId);
|
||||
|
||||
expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforceOrganizationDataOwnership", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
function setupMocksForEnforcementScenario(options: {
|
||||
featureEnabled?: boolean;
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("does nothing when feature flag is disabled", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
featureEnabled: false,
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
);
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no migration is required", async () => {
|
||||
setupMocksForEnforcementScenario({ policies: [] });
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when user has no personal ciphers", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs warning and returns when default collection is missing", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith(
|
||||
"Default collection is missing for user during organization data ownership enforcement",
|
||||
);
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not transfer items when user declines and confirms leaving", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines transfer, then confirms leaving
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "leftOrganization",
|
||||
});
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transfers items and shows success toast when user accepts transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[collectionId],
|
||||
userId,
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "itemsTransferred",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when transfer fails", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"Error transferring personal items to organization",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-shows transfer dialog when user goes back from leave confirmation", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, then accepts
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Dialog should have been opened 3 times: transfer -> leave -> transfer (after going back)
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(3);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows multiple back navigations before accepting transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, declines again, goes back again, then accepts
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Dialog should have been opened 5 times
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(5);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows user to go back and then confirm leaving", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, declines again, then confirms leaving
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(4);
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event logs", () => {
|
||||
it("logs accepted event when user accepts transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockEventCollectionService.collect).toHaveBeenCalledWith(
|
||||
EventType.Organization_ItemOrganization_Accepted,
|
||||
undefined,
|
||||
undefined,
|
||||
organizationId,
|
||||
);
|
||||
});
|
||||
|
||||
it("logs declined event when user rejects transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockEventCollectionService.collect).toHaveBeenCalledWith(
|
||||
EventType.Organization_ItemOrganization_Declined,
|
||||
undefined,
|
||||
undefined,
|
||||
organizationId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferInProgress$", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
function setupMocksForTransferScenario(options: {
|
||||
featureEnabled?: boolean;
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("emits false initially", async () => {
|
||||
const result = await firstValueFrom(service.transferInProgress$);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("emits true during transfer and false after successful completion", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForTransferScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
// Subscribe to track all emitted values
|
||||
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Should have emitted: false (initial), true (transfer started), false (transfer completed)
|
||||
expect(transferInProgressValues).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("emits false after transfer fails with error", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForTransferScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
|
||||
|
||||
// Subscribe to track all emitted values
|
||||
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Should have emitted: false (initial), true (transfer started), false (transfer failed)
|
||||
expect(transferInProgressValues).toEqual([false, true, false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
288
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal file
288
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
firstValueFrom,
|
||||
switchMap,
|
||||
map,
|
||||
of,
|
||||
Observable,
|
||||
combineLatest,
|
||||
BehaviorSubject,
|
||||
} from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
VaultItemsTransferService,
|
||||
UserMigrationInfo,
|
||||
} from "../abstractions/vault-items-transfer.service";
|
||||
import {
|
||||
TransferItemsDialogComponent,
|
||||
TransferItemsDialogResult,
|
||||
LeaveConfirmationDialogComponent,
|
||||
LeaveConfirmationDialogResult,
|
||||
} from "../components/vault-items-transfer";
|
||||
|
||||
@Injectable()
|
||||
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private policyService: PolicyService,
|
||||
private organizationService: OrganizationService,
|
||||
private collectionService: CollectionService,
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private eventCollectionService: EventCollectionService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
) {}
|
||||
|
||||
private _transferInProgressSubject = new BehaviorSubject(false);
|
||||
|
||||
transferInProgress$ = this._transferInProgressSubject.asObservable();
|
||||
|
||||
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
|
||||
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
|
||||
map(
|
||||
(policies) =>
|
||||
policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0],
|
||||
),
|
||||
switchMap((policy) => {
|
||||
if (policy == null) {
|
||||
return of(undefined);
|
||||
}
|
||||
return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private personalCiphers$(userId: UserId): Observable<CipherView[]> {
|
||||
return this.cipherService.cipherViews$(userId).pipe(
|
||||
filterOutNullish(),
|
||||
map((ciphers) => ciphers.filter((c) => c.organizationId == null)),
|
||||
);
|
||||
}
|
||||
|
||||
userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo> {
|
||||
return this.enforcingOrganization$(userId).pipe(
|
||||
switchMap((enforcingOrganization) => {
|
||||
if (enforcingOrganization == null) {
|
||||
return of<UserMigrationInfo>({
|
||||
requiresMigration: false,
|
||||
});
|
||||
}
|
||||
return combineLatest([
|
||||
this.personalCiphers$(userId),
|
||||
this.collectionService.defaultUserCollection$(userId, enforcingOrganization.id),
|
||||
]).pipe(
|
||||
map(([personalCiphers, defaultCollection]): UserMigrationInfo => {
|
||||
return {
|
||||
requiresMigration: personalCiphers.length > 0,
|
||||
enforcingOrganization,
|
||||
defaultCollectionId: defaultCollection?.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to accept or decline the vault items transfer.
|
||||
* If declined, shows a leave confirmation dialog with option to go back.
|
||||
* @returns true if user accepts transfer, false if user confirms leaving
|
||||
*/
|
||||
private async promptUserForTransfer(organizationName: string): Promise<boolean> {
|
||||
const confirmDialogRef = TransferItemsDialogComponent.open(this.dialogService, {
|
||||
data: { organizationName },
|
||||
});
|
||||
|
||||
const confirmResult = await firstValueFrom(confirmDialogRef.closed);
|
||||
|
||||
if (confirmResult === TransferItemsDialogResult.Accepted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const leaveDialogRef = LeaveConfirmationDialogComponent.open(this.dialogService, {
|
||||
data: { organizationName },
|
||||
});
|
||||
|
||||
const leaveResult = await firstValueFrom(leaveDialogRef.closed);
|
||||
|
||||
if (leaveResult === LeaveConfirmationDialogResult.Back) {
|
||||
return this.promptUserForTransfer(organizationName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
|
||||
const featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId));
|
||||
|
||||
if (!migrationInfo.requiresMigration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (migrationInfo.defaultCollectionId == null) {
|
||||
// TODO: Handle creating the default collection if missing (to be handled by AC in future work)
|
||||
this.logService.warning(
|
||||
"Default collection is missing for user during organization data ownership enforcement",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userAcceptedTransfer = await this.promptUserForTransfer(
|
||||
migrationInfo.enforcingOrganization.name,
|
||||
);
|
||||
|
||||
if (!userAcceptedTransfer) {
|
||||
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("leftOrganization"),
|
||||
});
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Organization_ItemOrganization_Declined,
|
||||
undefined,
|
||||
undefined,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._transferInProgressSubject.next(true);
|
||||
await this.transferPersonalItems(
|
||||
userId,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
migrationInfo.defaultCollectionId,
|
||||
);
|
||||
this._transferInProgressSubject.next(false);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsTransferred"),
|
||||
});
|
||||
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Organization_ItemOrganization_Accepted,
|
||||
undefined,
|
||||
undefined,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
);
|
||||
} catch (error) {
|
||||
this._transferInProgressSubject.next(false);
|
||||
this.logService.error("Error transferring personal items to organization", error);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async transferPersonalItems(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
defaultCollectionId: CollectionId,
|
||||
): Promise<void> {
|
||||
let personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
|
||||
|
||||
if (personalCiphers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments);
|
||||
|
||||
if (oldAttachmentCiphers.length > 0) {
|
||||
await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId);
|
||||
personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
|
||||
|
||||
// Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail
|
||||
const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments);
|
||||
if (remainingOldAttachments.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await this.cipherService.shareManyWithServer(
|
||||
personalCiphers,
|
||||
organizationId,
|
||||
[defaultCollectionId],
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades old attachments that don't have attachment keys.
|
||||
* Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key.
|
||||
*/
|
||||
private async upgradeOldAttachments(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<void> {
|
||||
this.logService.info(
|
||||
`Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
try {
|
||||
if (!cipher.hasOldAttachments) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId);
|
||||
|
||||
if (upgraded.hasOldAttachments) {
|
||||
this.logService.error(
|
||||
`Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`,
|
||||
);
|
||||
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
189
libs/vault/src/services/routed-vault-filter-bridge.service.ts
Normal file
189
libs/vault/src/services/routed-vault-filter-bridge.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import {
|
||||
VaultFilterServiceAbstraction as VaultFilterService,
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridge,
|
||||
RoutedVaultFilterModel,
|
||||
All,
|
||||
VaultFilter,
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
/**
|
||||
* This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method.
|
||||
* This should be removed after we have refactored the {@link VaultItemsComponent} and introduced vertical navigation
|
||||
* (which will refactor the {@link VaultFilterComponent}).
|
||||
*
|
||||
* This class listens to both the new {@link RoutedVaultFilterService} and the old {@link VaultFilterService}.
|
||||
* When a new filter is emitted the service uses the ids to find the corresponding tree nodes needed for
|
||||
* the old {@link VaultFilter} model. It then emits a bridge model that contains this information.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoutedVaultFilterBridgeService {
|
||||
readonly activeFilter$: Observable<VaultFilter>;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private routedVaultFilterService: RoutedVaultFilterService,
|
||||
legacyVaultFilterService: VaultFilterService,
|
||||
) {
|
||||
this.activeFilter$ = combineLatest([
|
||||
routedVaultFilterService.filter$,
|
||||
legacyVaultFilterService.collectionTree$,
|
||||
legacyVaultFilterService.folderTree$,
|
||||
legacyVaultFilterService.organizationTree$,
|
||||
legacyVaultFilterService.cipherTypeTree$,
|
||||
]).pipe(
|
||||
map(([filter, collectionTree, folderTree, organizationTree, cipherTypeTree]) => {
|
||||
const legacyFilter = isAdminConsole(filter)
|
||||
? createLegacyFilterForAdminConsole(filter, collectionTree, cipherTypeTree)
|
||||
: createLegacyFilterForEndUser(
|
||||
filter,
|
||||
collectionTree,
|
||||
folderTree,
|
||||
organizationTree,
|
||||
cipherTypeTree,
|
||||
);
|
||||
|
||||
return new RoutedVaultFilterBridge(filter, legacyFilter, this);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
navigate(filter: RoutedVaultFilterModel) {
|
||||
const [commands, extras] = this.routedVaultFilterService.createRoute(filter);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(commands, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the filtering is being done as part of admin console.
|
||||
* Admin console can be identified by checking if the `organizationId`
|
||||
* is part of the path.
|
||||
*
|
||||
* @param filter Model to check if origin is admin console
|
||||
* @returns true if filtering being done as part of admin console
|
||||
*/
|
||||
function isAdminConsole(filter: RoutedVaultFilterModel) {
|
||||
return filter.organizationIdParamType === "path";
|
||||
}
|
||||
|
||||
function createLegacyFilterForAdminConsole(
|
||||
filter: RoutedVaultFilterModel,
|
||||
collectionTree: TreeNode<CollectionFilter>,
|
||||
cipherTypeTree: TreeNode<CipherTypeFilter>,
|
||||
): VaultFilter {
|
||||
const legacyFilter = new VaultFilter();
|
||||
|
||||
if (filter.collectionId === undefined && filter.type === undefined) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
|
||||
collectionTree,
|
||||
"AllCollections",
|
||||
);
|
||||
} else if (filter.collectionId !== undefined && filter.collectionId === Unassigned) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(collectionTree, null);
|
||||
} else if (filter.collectionId !== undefined) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
|
||||
collectionTree,
|
||||
filter.collectionId,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.collectionId === undefined && filter.type === All) {
|
||||
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
|
||||
cipherTypeTree,
|
||||
"AllItems",
|
||||
);
|
||||
} else if (filter.type !== undefined && filter.type === "trash") {
|
||||
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
|
||||
{ id: "trash", name: "", type: "trash", icon: "" },
|
||||
null,
|
||||
);
|
||||
} else if (filter.type !== undefined && filter.type !== "trash") {
|
||||
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
|
||||
cipherTypeTree,
|
||||
filter.type,
|
||||
);
|
||||
}
|
||||
|
||||
return legacyFilter;
|
||||
}
|
||||
|
||||
function createLegacyFilterForEndUser(
|
||||
filter: RoutedVaultFilterModel,
|
||||
collectionTree: TreeNode<CollectionFilter>,
|
||||
folderTree: TreeNode<FolderFilter>,
|
||||
organizationTree: TreeNode<OrganizationFilter>,
|
||||
cipherTypeTree: TreeNode<CipherTypeFilter>,
|
||||
): VaultFilter {
|
||||
const legacyFilter = new VaultFilter();
|
||||
|
||||
if (filter.collectionId !== undefined && filter.collectionId === Unassigned) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(collectionTree, null);
|
||||
} else if (filter.collectionId !== undefined && filter.collectionId === All) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
|
||||
collectionTree,
|
||||
"AllCollections",
|
||||
);
|
||||
} else if (filter.collectionId !== undefined) {
|
||||
legacyFilter.selectedCollectionNode = ServiceUtils.getTreeNodeObject(
|
||||
collectionTree,
|
||||
filter.collectionId,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.folderId !== undefined && filter.folderId === Unassigned) {
|
||||
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, null);
|
||||
} else if (filter.folderId !== undefined && filter.folderId !== Unassigned) {
|
||||
legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId);
|
||||
}
|
||||
|
||||
if (filter.organizationId !== undefined && filter.organizationId === Unassigned) {
|
||||
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
|
||||
organizationTree,
|
||||
"MyVault",
|
||||
);
|
||||
} else if (filter.organizationId !== undefined && filter.organizationId !== Unassigned) {
|
||||
legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject(
|
||||
organizationTree,
|
||||
filter.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.type === undefined) {
|
||||
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
|
||||
cipherTypeTree,
|
||||
"AllItems",
|
||||
);
|
||||
} else if (filter.type !== undefined && filter.type === "trash") {
|
||||
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
|
||||
{ id: "trash", name: "", type: "trash", icon: "" },
|
||||
null,
|
||||
);
|
||||
} else if (filter.type !== undefined && filter.type === "archive") {
|
||||
legacyFilter.selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(
|
||||
{ id: "archive", name: "", type: "archive", icon: "" },
|
||||
null,
|
||||
);
|
||||
} else if (filter.type !== undefined && filter.type !== "trash") {
|
||||
legacyFilter.selectedCipherTypeNode = ServiceUtils.getTreeNodeObject(
|
||||
cipherTypeTree,
|
||||
filter.type,
|
||||
);
|
||||
}
|
||||
|
||||
return legacyFilter;
|
||||
}
|
||||
95
libs/vault/src/services/routed-vault-filter.service.ts
Normal file
95
libs/vault/src/services/routed-vault-filter.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable, OnDestroy, inject } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationExtras } from "@angular/router";
|
||||
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
|
||||
import {
|
||||
isRoutedVaultFilterItemType,
|
||||
RoutedVaultFilterModel,
|
||||
} from "../models/routed-vault-filter.model";
|
||||
|
||||
/**
|
||||
* Injection token for the base route path used in vault filter navigation.
|
||||
*/
|
||||
export const VAULT_FILTER_BASE_ROUTE = new SafeInjectionToken<string>("VaultFilterBaseRoute");
|
||||
|
||||
/**
|
||||
* This service is an abstraction layer on top of ActivatedRoute that
|
||||
* encapsulates the logic of how filters are stored in the URL.
|
||||
*
|
||||
* The service builds and emits filter models based on URL params and
|
||||
* also contains a method for generating routes to corresponding to those params.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoutedVaultFilterService implements OnDestroy {
|
||||
private onDestroy = new Subject<void>();
|
||||
private baseRoute: string = inject(VAULT_FILTER_BASE_ROUTE, { optional: true }) ?? "";
|
||||
|
||||
/**
|
||||
* Filter values extracted from the URL.
|
||||
* To change the values use {@link RoutedVaultFilterService.createRoute}.
|
||||
*/
|
||||
filter$: Observable<RoutedVaultFilterModel>;
|
||||
|
||||
constructor(activatedRoute: ActivatedRoute) {
|
||||
this.filter$ = combineLatest([activatedRoute.paramMap, activatedRoute.queryParamMap]).pipe(
|
||||
map(([params, queryParams]) => {
|
||||
const unsafeType = queryParams.get("type");
|
||||
const type = isRoutedVaultFilterItemType(unsafeType) ? unsafeType : undefined;
|
||||
|
||||
return {
|
||||
collectionId: (queryParams.get("collectionId") as CollectionId) ?? undefined,
|
||||
folderId: queryParams.get("folderId") ?? undefined,
|
||||
organizationId:
|
||||
(params.get("organizationId") as OrganizationId) ??
|
||||
(queryParams.get("organizationId") as OrganizationId) ??
|
||||
undefined,
|
||||
organizationIdParamType:
|
||||
params.get("organizationId") != undefined ? ("path" as const) : ("query" as const),
|
||||
type,
|
||||
};
|
||||
}),
|
||||
takeUntil(this.onDestroy),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route that can be used to modify filters with Router or RouterLink.
|
||||
* This method is specifically built to leave other query parameters untouched,
|
||||
* meaning that navigation will only affect filters and not e.g. `cipherId`.
|
||||
* To subscribe to changes use {@link RoutedVaultFilterService.filter$}.
|
||||
*
|
||||
* Note:
|
||||
* This method currently only supports changing filters that are stored
|
||||
* in query parameters. This means that {@link RoutedVaultFilterModel.organizationId}
|
||||
* will be ignored if {@link RoutedVaultFilterModel.organizationIdParamType}
|
||||
* is set to `path`.
|
||||
*
|
||||
* @param filter Filter values that should be applied to the URL.
|
||||
* @returns route that can be used with Router or RouterLink
|
||||
*/
|
||||
createRoute(filter: RoutedVaultFilterModel): [commands: any[], extras?: NavigationExtras] {
|
||||
const commands: string[] = this.baseRoute ? [this.baseRoute] : [];
|
||||
const extras: NavigationExtras = {
|
||||
queryParams: {
|
||||
collectionId: filter.collectionId ?? null,
|
||||
folderId: filter.folderId ?? null,
|
||||
organizationId:
|
||||
filter.organizationIdParamType === "path" ? null : (filter.organizationId ?? null),
|
||||
type: filter.type ?? null,
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
},
|
||||
};
|
||||
return [commands, extras];
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
}
|
||||
405
libs/vault/src/services/vault-filter.service.spec.ts
Normal file
405
libs/vault/src/services/vault-filter.service.spec.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/../spec/fake-account-service";
|
||||
import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state";
|
||||
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of, ReplaySubject } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
|
||||
|
||||
import { VaultFilterService } from "./vault-filter.service";
|
||||
|
||||
jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({
|
||||
sortDefaultCollections: jest.fn((): CollectionView[] => []),
|
||||
}));
|
||||
|
||||
describe("vault filter service", () => {
|
||||
let vaultFilterService: VaultFilterService;
|
||||
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let folderService: MockProxy<FolderService>;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let collectionService: MockProxy<CollectionService>;
|
||||
let organizations: ReplaySubject<Organization[]>;
|
||||
let folderViews: ReplaySubject<FolderView[]>;
|
||||
let collectionViews: ReplaySubject<CollectionView[]>;
|
||||
let cipherViews: ReplaySubject<CipherView[]>;
|
||||
let organizationDataOwnershipPolicy: ReplaySubject<boolean>;
|
||||
let singleOrgPolicy: ReplaySubject<boolean>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
let collapsedGroupingsState: FakeSingleUserState<string[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
organizationService = mock<OrganizationService>();
|
||||
folderService = mock<FolderService>();
|
||||
cipherService = mock<CipherService>();
|
||||
policyService = mock<PolicyService>();
|
||||
i18nService = mock<I18nService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
i18nService.collator = new Intl.Collator("en-US");
|
||||
collectionService = mock<CollectionService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
organizations = new ReplaySubject<Organization[]>(1);
|
||||
folderViews = new ReplaySubject<FolderView[]>(1);
|
||||
collectionViews = new ReplaySubject<CollectionView[]>(1);
|
||||
cipherViews = new ReplaySubject<CipherView[]>(1);
|
||||
organizationDataOwnershipPolicy = new ReplaySubject<boolean>(1);
|
||||
singleOrgPolicy = new ReplaySubject<boolean>(1);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
organizationService.memberOrganizations$.mockReturnValue(organizations);
|
||||
folderService.folderViews$.mockReturnValue(folderViews);
|
||||
collectionService.decryptedCollections$.mockReturnValue(collectionViews);
|
||||
policyService.policyAppliesToUser$
|
||||
.calledWith(PolicyType.OrganizationDataOwnership, mockUserId)
|
||||
.mockReturnValue(organizationDataOwnershipPolicy);
|
||||
policyService.policyAppliesToUser$
|
||||
.calledWith(PolicyType.SingleOrg, mockUserId)
|
||||
.mockReturnValue(singleOrgPolicy);
|
||||
cipherService.cipherListViews$.mockReturnValue(cipherViews);
|
||||
|
||||
vaultFilterService = new VaultFilterService(
|
||||
organizationService,
|
||||
folderService,
|
||||
cipherService,
|
||||
policyService,
|
||||
i18nService,
|
||||
stateProvider,
|
||||
collectionService,
|
||||
accountService,
|
||||
);
|
||||
collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS);
|
||||
organizations.next([]);
|
||||
});
|
||||
|
||||
describe("collapsed filter nodes", () => {
|
||||
const nodes = new Set(["1", "2"]);
|
||||
|
||||
it("should update the collapsedFilterNodes$", async () => {
|
||||
await vaultFilterService.setCollapsedFilterNodes(nodes, mockUserId);
|
||||
|
||||
const collapsedGroupingsState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
COLLAPSED_GROUPINGS,
|
||||
);
|
||||
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
|
||||
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith(Array.from(nodes));
|
||||
});
|
||||
|
||||
it("loads from state on initialization", async () => {
|
||||
collapsedGroupingsState.nextState(["1", "2"]);
|
||||
|
||||
await expect(firstValueFrom(vaultFilterService.collapsedFilterNodes$)).resolves.toEqual(
|
||||
nodes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizations", () => {
|
||||
beforeEach(() => {
|
||||
const storedOrgs = [
|
||||
createOrganization("1" as OrganizationId, "org1"),
|
||||
createOrganization("2" as OrganizationId, "org2"),
|
||||
];
|
||||
organizations.next(storedOrgs);
|
||||
organizationDataOwnershipPolicy.next(false);
|
||||
singleOrgPolicy.next(false);
|
||||
});
|
||||
|
||||
it("returns a nested tree", async () => {
|
||||
const tree = await firstValueFrom(vaultFilterService.organizationTree$);
|
||||
|
||||
expect(tree.children.length).toBe(3);
|
||||
expect(tree.children.find((o) => o.node.name === "org1"));
|
||||
expect(tree.children.find((o) => o.node.name === "org2"));
|
||||
});
|
||||
|
||||
it("hides My Vault if organization data ownership policy is enabled", async () => {
|
||||
organizationDataOwnershipPolicy.next(true);
|
||||
|
||||
const tree = await firstValueFrom(vaultFilterService.organizationTree$);
|
||||
|
||||
expect(tree.children.length).toBe(2);
|
||||
expect(!tree.children.find((o) => o.node.id === "MyVault"));
|
||||
});
|
||||
|
||||
it("returns 1 organization and My Vault if single organization policy is enabled", async () => {
|
||||
singleOrgPolicy.next(true);
|
||||
|
||||
const tree = await firstValueFrom(vaultFilterService.organizationTree$);
|
||||
|
||||
expect(tree.children.length).toBe(2);
|
||||
expect(tree.children.find((o) => o.node.name === "org1"));
|
||||
expect(tree.children.find((o) => o.node.id === "MyVault"));
|
||||
});
|
||||
|
||||
it("returns 1 organization if both single organization and organization data ownership policies are enabled", async () => {
|
||||
singleOrgPolicy.next(true);
|
||||
organizationDataOwnershipPolicy.next(true);
|
||||
|
||||
const tree = await firstValueFrom(vaultFilterService.organizationTree$);
|
||||
|
||||
expect(tree.children.length).toBe(1);
|
||||
expect(tree.children.find((o) => o.node.name === "org1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("folders", () => {
|
||||
describe("filtered folders with organization", () => {
|
||||
beforeEach(() => {
|
||||
// Org must be updated before folderService else the subscription uses the null org default value
|
||||
vaultFilterService.setOrganizationFilter(
|
||||
createOrganization("org test id" as OrganizationId, "Test Org"),
|
||||
);
|
||||
});
|
||||
it("returns folders filtered by current organization", async () => {
|
||||
const storedCiphers = [
|
||||
createCipherView("1", "org test id", "folder test id"),
|
||||
createCipherView("2", "non matching org id", "non matching folder id"),
|
||||
];
|
||||
cipherViews.next(storedCiphers);
|
||||
|
||||
const storedFolders = [
|
||||
createFolderView("folder test id", "test"),
|
||||
createFolderView("non matching folder id", "test2"),
|
||||
];
|
||||
folderViews.next(storedFolders);
|
||||
|
||||
await expect(firstValueFrom(vaultFilterService.filteredFolders$)).resolves.toMatchObject([
|
||||
{ id: "folder test id", name: "test" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns current organization", () => {
|
||||
vaultFilterService.getOrganizationFilter().subscribe((org) => {
|
||||
expect(org.id).toEqual("org test id");
|
||||
expect(org.identifier).toEqual("Test Org");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("folder tree", () => {
|
||||
it("returns a nested tree", async () => {
|
||||
const storedFolders = [
|
||||
createFolderView("Folder 1 Id", "Folder 1"),
|
||||
createFolderView("Folder 2 Id", "Folder 1/Folder 2"),
|
||||
createFolderView("Folder 3 Id", "Folder 1/Folder 3"),
|
||||
];
|
||||
folderViews.next(storedFolders);
|
||||
cipherViews.next([]);
|
||||
|
||||
const result = await firstValueFrom(vaultFilterService.folderTree$);
|
||||
|
||||
expect(result.children[0].node.id === "Folder 1 Id");
|
||||
expect(result.children[0].children.find((c) => c.node.id === "Folder 2 Id"));
|
||||
expect(result.children[0].children.find((c) => c.node.id === "Folder 3 Id"));
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collections", () => {
|
||||
describe("filtered collections", () => {
|
||||
it("returns collections filtered by current organization", async () => {
|
||||
vaultFilterService.setOrganizationFilter(
|
||||
createOrganization("org test id" as OrganizationId, "Test Org"),
|
||||
);
|
||||
|
||||
const storedCollections = [
|
||||
createCollectionView("1", "collection 1", "org test id"),
|
||||
createCollectionView("2", "collection 2", "non matching org id"),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
|
||||
await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([
|
||||
createCollectionView("1", "collection 1", "org test id"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collection tree", () => {
|
||||
it("returns tree with children", async () => {
|
||||
const storedCollections = [
|
||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
|
||||
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
collectionService.groupByOrganization.mockReturnValue(
|
||||
new Map([["org test id" as OrganizationId, storedCollections]]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||
|
||||
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
|
||||
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-2", "id-3"]);
|
||||
});
|
||||
|
||||
it("returns tree where non-existing collections are excluded from children", async () => {
|
||||
const storedCollections = [
|
||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
collectionService.groupByOrganization.mockReturnValue(
|
||||
new Map([["org test id" as OrganizationId, storedCollections]]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||
|
||||
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
|
||||
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-3"]);
|
||||
expect(result.children[0].children[0].node.name).toBe("Collection 2/Collection 3");
|
||||
});
|
||||
|
||||
it("returns tree with parents", async () => {
|
||||
const storedCollections = [
|
||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
|
||||
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
collectionService.groupByOrganization.mockReturnValue(
|
||||
new Map([["org test id" as OrganizationId, storedCollections]]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||
|
||||
const c1 = result.children[0];
|
||||
const c2 = c1.children[0];
|
||||
const c3 = c2.children[0];
|
||||
const c4 = c1.children[1];
|
||||
expect(c2.parent.node.id).toEqual("id-1");
|
||||
expect(c3.parent.node.id).toEqual("id-2");
|
||||
expect(c4.parent.node.id).toEqual("id-1");
|
||||
});
|
||||
|
||||
it("returns tree where non-existing collections are excluded from parents", async () => {
|
||||
const storedCollections = [
|
||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
collectionService.groupByOrganization.mockReturnValue(
|
||||
new Map([["org test id" as OrganizationId, storedCollections]]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||
|
||||
const c1 = result.children[0];
|
||||
const c3 = c1.children[0];
|
||||
expect(c3.parent.node.id).toEqual("id-1");
|
||||
});
|
||||
|
||||
it("calls sortDefaultCollections with the correct args", async () => {
|
||||
const storedOrgs = [
|
||||
createOrganization("id-defaultOrg1" as OrganizationId, "org1"),
|
||||
createOrganization("id-defaultOrg2" as OrganizationId, "org2"),
|
||||
];
|
||||
organizations.next(storedOrgs);
|
||||
|
||||
const storedCollections = [
|
||||
createCollectionView("id-2", "Collection 2", "org test id"),
|
||||
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||
createCollectionView(
|
||||
"id-3",
|
||||
"Default User Collection - Org 2",
|
||||
"id-defaultOrg2",
|
||||
CollectionTypes.DefaultUserCollection,
|
||||
),
|
||||
createCollectionView(
|
||||
"id-4",
|
||||
"Default User Collection - Org 1",
|
||||
"id-defaultOrg1",
|
||||
CollectionTypes.DefaultUserCollection,
|
||||
),
|
||||
];
|
||||
collectionViews.next(storedCollections);
|
||||
collectionService.groupByOrganization.mockReturnValue(
|
||||
new Map([["org test id" as OrganizationId, storedCollections]]),
|
||||
);
|
||||
|
||||
await firstValueFrom(vaultFilterService.collectionTree$);
|
||||
|
||||
expect(vaultFilterSvc.sortDefaultCollections).toHaveBeenCalledWith(
|
||||
storedCollections,
|
||||
storedOrgs,
|
||||
i18nService.collator,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createOrganization(id: OrganizationId, name: string) {
|
||||
const org = new Organization();
|
||||
org.id = id;
|
||||
org.name = name;
|
||||
org.identifier = name;
|
||||
org.isMember = true;
|
||||
return org;
|
||||
}
|
||||
|
||||
function createCipherView(id: string, orgId: string, folderId: string) {
|
||||
const cipher = new CipherView();
|
||||
cipher.id = id;
|
||||
cipher.organizationId = orgId;
|
||||
cipher.folderId = folderId;
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function createFolderView(id: string, name: string): FolderView {
|
||||
const folder = new FolderView();
|
||||
folder.id = id;
|
||||
folder.name = name;
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createCollectionView(
|
||||
id: string,
|
||||
name: string,
|
||||
orgId: string,
|
||||
type?: CollectionType,
|
||||
): CollectionView {
|
||||
const collection = new CollectionView({
|
||||
id: id as CollectionId,
|
||||
name,
|
||||
organizationId: orgId as OrganizationId,
|
||||
});
|
||||
|
||||
if (type) {
|
||||
collection.type = type;
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
});
|
||||
361
libs/vault/src/services/vault-filter.service.ts
Normal file
361
libs/vault/src/services/vault-filter.service.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
VaultFilterServiceAbstraction,
|
||||
CipherTypeFilter,
|
||||
CollectionFilter,
|
||||
FolderFilter,
|
||||
OrganizationFilter,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
const NestingDelimiter = "/";
|
||||
|
||||
@Injectable()
|
||||
export class VaultFilterService implements VaultFilterServiceAbstraction {
|
||||
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
memberOrganizations$ = this.activeUserId$.pipe(
|
||||
switchMap((id) => this.organizationService.memberOrganizations$(id)),
|
||||
);
|
||||
|
||||
collapsedFilterNodes$ = this.activeUserId$.pipe(
|
||||
switchMap((id) => this.collapsedGroupingsState(id).state$),
|
||||
map((state) => new Set(state)),
|
||||
);
|
||||
|
||||
organizationTree$: Observable<TreeNode<OrganizationFilter>> = combineLatest([
|
||||
this.memberOrganizations$,
|
||||
this.activeUserId$.pipe(
|
||||
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
|
||||
),
|
||||
this.activeUserId$.pipe(
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
),
|
||||
),
|
||||
]).pipe(
|
||||
switchMap(([orgs, singleOrgPolicy, organizationDataOwnershipPolicy]) =>
|
||||
this.buildOrganizationTree(orgs, singleOrgPolicy, organizationDataOwnershipPolicy),
|
||||
),
|
||||
);
|
||||
|
||||
protected _organizationFilter = new BehaviorSubject<Organization>(null);
|
||||
|
||||
filteredFolders$: Observable<FolderView[]> = this.activeUserId$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.folderService.folderViews$(userId),
|
||||
this.cipherService.cipherListViews$(userId),
|
||||
this._organizationFilter,
|
||||
]),
|
||||
),
|
||||
filter(([folders, ciphers, org]) => !!ciphers), // ciphers may be null, meaning decryption is in progress. Ignore this emission
|
||||
switchMap(([folders, ciphers, org]) => {
|
||||
return this.filterFolders(folders, ciphers, org);
|
||||
}),
|
||||
);
|
||||
|
||||
folderTree$: Observable<TreeNode<FolderFilter>> = this.filteredFolders$.pipe(
|
||||
map((folders) => this.buildFolderTree(folders)),
|
||||
);
|
||||
|
||||
filteredCollections$: Observable<CollectionView[]> = combineLatest([
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
|
||||
),
|
||||
this._organizationFilter,
|
||||
]).pipe(switchMap(([collections, org]) => this.filterCollections(collections, org)));
|
||||
|
||||
collectionTree$: Observable<TreeNode<CollectionFilter>> = combineLatest([
|
||||
this.filteredCollections$,
|
||||
this.memberOrganizations$,
|
||||
]).pipe(
|
||||
map(([collections, organizations]) => this.buildCollectionTree(collections, organizations)),
|
||||
);
|
||||
|
||||
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>> = this.buildCipherTypeTree();
|
||||
|
||||
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
|
||||
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected organizationService: OrganizationService,
|
||||
protected folderService: FolderService,
|
||||
protected cipherService: CipherService,
|
||||
protected policyService: PolicyService,
|
||||
protected i18nService: I18nService,
|
||||
protected stateProvider: StateProvider,
|
||||
protected collectionService: CollectionService,
|
||||
protected accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async getCollectionNodeFromTree(id: string) {
|
||||
const collections = await firstValueFrom(this.collectionTree$);
|
||||
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionFilter>;
|
||||
}
|
||||
|
||||
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>, userId: UserId): Promise<void> {
|
||||
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
|
||||
}
|
||||
|
||||
protected async getCollapsedFilterNodes(): Promise<Set<string>> {
|
||||
return await firstValueFrom(this.collapsedFilterNodes$);
|
||||
}
|
||||
|
||||
getOrganizationFilter() {
|
||||
return this._organizationFilter;
|
||||
}
|
||||
|
||||
clearOrganizationFilter() {
|
||||
this._organizationFilter.next(null);
|
||||
}
|
||||
|
||||
setOrganizationFilter(organization: Organization) {
|
||||
if (organization?.id != "AllVaults") {
|
||||
this._organizationFilter.next(organization);
|
||||
} else {
|
||||
this._organizationFilter.next(null);
|
||||
}
|
||||
}
|
||||
|
||||
async expandOrgFilter(userId: UserId) {
|
||||
const collapsedFilterNodes = await firstValueFrom(this.collapsedFilterNodes$);
|
||||
if (!collapsedFilterNodes.has("AllVaults")) {
|
||||
return;
|
||||
}
|
||||
collapsedFilterNodes.delete("AllVaults");
|
||||
await this.setCollapsedFilterNodes(collapsedFilterNodes, userId);
|
||||
}
|
||||
|
||||
protected async buildOrganizationTree(
|
||||
orgs: Organization[],
|
||||
singleOrgPolicy: boolean,
|
||||
organizationDataOwnershipPolicy: boolean,
|
||||
): Promise<TreeNode<OrganizationFilter>> {
|
||||
const headNode = this.getOrganizationFilterHead();
|
||||
if (!organizationDataOwnershipPolicy) {
|
||||
const myVaultNode = this.getOrganizationFilterMyVault();
|
||||
headNode.children.push(myVaultNode);
|
||||
}
|
||||
if (singleOrgPolicy) {
|
||||
orgs = orgs.slice(0, 1);
|
||||
}
|
||||
if (orgs) {
|
||||
const orgNodes: TreeNode<OrganizationFilter>[] = [];
|
||||
orgs.forEach((org) => {
|
||||
const orgCopy = org as OrganizationFilter;
|
||||
orgCopy.icon = "bwi-business";
|
||||
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);
|
||||
orgNodes.push(node);
|
||||
});
|
||||
// Sort organization nodes, then add them to the list after 'My Vault' and 'All Vaults' if present
|
||||
orgNodes.sort((a, b) => a.node.name.localeCompare(b.node.name));
|
||||
headNode.children.push(...orgNodes);
|
||||
}
|
||||
return headNode;
|
||||
}
|
||||
|
||||
protected getOrganizationFilterHead(): TreeNode<OrganizationFilter> {
|
||||
const head = new Organization() as OrganizationFilter;
|
||||
head.enabled = true;
|
||||
return new TreeNode<OrganizationFilter>(head, null, "allVaults", "AllVaults");
|
||||
}
|
||||
|
||||
protected getOrganizationFilterMyVault(): TreeNode<OrganizationFilter> {
|
||||
const myVault = new Organization() as OrganizationFilter;
|
||||
myVault.id = "MyVault" as OrganizationId;
|
||||
myVault.icon = "bwi-user";
|
||||
myVault.enabled = true;
|
||||
myVault.hideOptions = true;
|
||||
return new TreeNode<OrganizationFilter>(myVault, null, this.i18nService.t("myVault"));
|
||||
}
|
||||
|
||||
buildTypeTree(
|
||||
head: CipherTypeFilter,
|
||||
array?: CipherTypeFilter[],
|
||||
): Observable<TreeNode<CipherTypeFilter>> {
|
||||
const headNode = new TreeNode<CipherTypeFilter>(head, null);
|
||||
array?.forEach((filter) => {
|
||||
const node = new TreeNode<CipherTypeFilter>(filter, headNode, filter.name);
|
||||
headNode.children.push(node);
|
||||
});
|
||||
return of(headNode);
|
||||
}
|
||||
|
||||
protected async filterCollections(
|
||||
storedCollections: CollectionView[],
|
||||
org?: Organization,
|
||||
): Promise<CollectionView[]> {
|
||||
return org?.id != null
|
||||
? storedCollections.filter((c) => c.organizationId === org.id)
|
||||
: storedCollections;
|
||||
}
|
||||
|
||||
protected buildCollectionTree(
|
||||
collections?: CollectionView[],
|
||||
orgs?: Organization[],
|
||||
): TreeNode<CollectionFilter> {
|
||||
const headNode = this.getCollectionFilterHead();
|
||||
if (!collections) {
|
||||
return headNode;
|
||||
}
|
||||
const all: TreeNode<CollectionFilter>[] = [];
|
||||
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
|
||||
const groupedByOrg = this.collectionService.groupByOrganization(collections);
|
||||
|
||||
for (const group of groupedByOrg.values()) {
|
||||
const nodes: TreeNode<CollectionFilter>[] = [];
|
||||
for (const c of group) {
|
||||
const collectionCopy = cloneCollection(
|
||||
new CollectionView({ ...c, name: c.name }),
|
||||
) as CollectionFilter;
|
||||
collectionCopy.icon =
|
||||
c.type === CollectionTypes.DefaultUserCollection ? "bwi-user" : "bwi-collection-shared";
|
||||
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
||||
}
|
||||
all.push(...nodes);
|
||||
}
|
||||
|
||||
all.forEach((n) => {
|
||||
n.parent = headNode;
|
||||
headNode.children.push(n);
|
||||
});
|
||||
|
||||
return headNode;
|
||||
}
|
||||
|
||||
protected getCollectionFilterHead(): TreeNode<CollectionFilter> {
|
||||
const head = CollectionView.vaultFilterHead() as CollectionFilter;
|
||||
return new TreeNode<CollectionFilter>(head, null, "collections", "AllCollections");
|
||||
}
|
||||
|
||||
protected async filterFolders(
|
||||
storedFolders: FolderView[],
|
||||
ciphers: CipherView[] | CipherListView[],
|
||||
org?: Organization,
|
||||
): Promise<FolderView[]> {
|
||||
// If no org or "My Vault" is selected, show all folders
|
||||
if (org?.id == null || org?.id == "MyVault") {
|
||||
return storedFolders;
|
||||
}
|
||||
|
||||
// Otherwise, show only folders that have ciphers from the selected org and the "no folder" folder
|
||||
const orgCiphers = ciphers.filter((c) => c.organizationId == org?.id);
|
||||
return storedFolders.filter((f) => orgCiphers.some((oc) => oc.folderId == f.id) || !f.id);
|
||||
}
|
||||
|
||||
protected buildFolderTree(folders?: FolderView[]): TreeNode<FolderFilter> {
|
||||
const headNode = this.getFolderFilterHead();
|
||||
if (!folders) {
|
||||
return headNode;
|
||||
}
|
||||
const nodes: TreeNode<FolderFilter>[] = [];
|
||||
folders.forEach((f) => {
|
||||
const folderCopy = new FolderView() as FolderFilter;
|
||||
folderCopy.id = f.id;
|
||||
folderCopy.revisionDate = f.revisionDate;
|
||||
folderCopy.icon = "bwi-folder";
|
||||
folderCopy.fullName = f.name; // save full folder name before separating it into parts
|
||||
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
|
||||
});
|
||||
|
||||
nodes.forEach((n) => {
|
||||
n.parent = headNode;
|
||||
headNode.children.push(n);
|
||||
});
|
||||
return headNode;
|
||||
}
|
||||
|
||||
protected getFolderFilterHead(): TreeNode<FolderFilter> {
|
||||
const head = new FolderView() as FolderFilter;
|
||||
return new TreeNode<FolderFilter>(head, null, "folders", "AllFolders");
|
||||
}
|
||||
|
||||
protected buildCipherTypeTree(): Observable<TreeNode<CipherTypeFilter>> {
|
||||
const allTypeFilters: CipherTypeFilter[] = [
|
||||
{
|
||||
id: "favorites",
|
||||
name: this.i18nService.t("favorites"),
|
||||
type: "favorites",
|
||||
icon: "bwi-star",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
name: this.i18nService.t("typeLogin"),
|
||||
type: CipherType.Login,
|
||||
icon: "bwi-globe",
|
||||
},
|
||||
{
|
||||
id: "card",
|
||||
name: this.i18nService.t("typeCard"),
|
||||
type: CipherType.Card,
|
||||
icon: "bwi-credit-card",
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
name: this.i18nService.t("typeIdentity"),
|
||||
type: CipherType.Identity,
|
||||
icon: "bwi-id-card",
|
||||
},
|
||||
{
|
||||
id: "note",
|
||||
name: this.i18nService.t("typeSecureNote"),
|
||||
type: CipherType.SecureNote,
|
||||
icon: "bwi-sticky-note",
|
||||
},
|
||||
{
|
||||
id: "sshKey",
|
||||
name: this.i18nService.t("typeSshKey"),
|
||||
type: CipherType.SshKey,
|
||||
icon: "bwi-key",
|
||||
},
|
||||
];
|
||||
|
||||
return this.buildTypeTree(
|
||||
{ id: "AllItems", name: "allItems", type: "all", icon: "" },
|
||||
allTypeFilters,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user