1
0
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:
jaasen-livefront
2026-01-20 15:12:12 -08:00
2022 changed files with 167714 additions and 40907 deletions

View 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;
}

View 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>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,9 @@
[organizationId]="organizationId"
[admin]="admin"
[submitBtn]="submitBtn"
(onUploadStarted)="uploadStarted()"
(onUploadSuccess)="uploadSuccessful()"
(onUploadFailed)="uploadFailed()"
(onRemoveSuccess)="removalSuccessful()"
></app-cipher-attachments>
</ng-container>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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;
};
}

View 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();
}
}

View 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";
}

View 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;
};

View 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;
}

View 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;
};
}
}

View 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 };

View File

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

View File

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

View File

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

View File

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

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

View 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;
}

View 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();
}
}

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

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