mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 11:53:34 +00:00
[PM-30296] Assign to Collections for Archived Ciphers (#18223)
* allow for archived ciphers to be assigned to a collection via the more options menu * reference `userId$` directly
This commit is contained in:
@@ -405,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAssignCollections$", () => {
|
||||
it("emits true when user has organizations and editable collections", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when user has no organizations", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when all collections are read-only", (done) => {
|
||||
jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true));
|
||||
jest
|
||||
.spyOn(component["collectionService"], "decryptedCollections$")
|
||||
.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,15 @@
|
||||
<button type="button" bitMenuItem (click)="clone(cipher)">
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
@if (canAssignCollections$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="conditionallyNavigateToAssignCollections(cipher)"
|
||||
>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitMenuItem (click)="unarchive(cipher)">
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
|
||||
135
apps/browser/src/vault/popup/settings/archive.component.spec.ts
Normal file
135
apps/browser/src/vault/popup/settings/archive.component.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { ArchiveComponent } from "./archive.component";
|
||||
|
||||
describe("ArchiveComponent", () => {
|
||||
let component: ArchiveComponent;
|
||||
|
||||
let hasOrganizations: jest.Mock;
|
||||
let decryptedCollections$: jest.Mock;
|
||||
let navigate: jest.Mock;
|
||||
let showPasswordPrompt: jest.Mock;
|
||||
|
||||
beforeAll(async () => {
|
||||
navigate = jest.fn();
|
||||
showPasswordPrompt = jest.fn().mockResolvedValue(true);
|
||||
hasOrganizations = jest.fn();
|
||||
decryptedCollections$ = jest.fn();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) },
|
||||
},
|
||||
{ provide: PasswordRepromptService, useValue: { showPasswordPrompt } },
|
||||
{ provide: OrganizationService, useValue: { hasOrganizations } },
|
||||
{ provide: CollectionService, useValue: { decryptedCollections$ } },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||
{ provide: CipherArchiveService, useValue: mock<CipherArchiveService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ArchiveComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("canAssignCollections$", () => {
|
||||
it("emits true when user has organizations and editable collections", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(true));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when user has no organizations", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(false));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("emits false when all collections are read-only", (done) => {
|
||||
hasOrganizations.mockReturnValue(of(true));
|
||||
decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any));
|
||||
|
||||
component["canAssignCollections$"].subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditionallyNavigateToAssignCollections", () => {
|
||||
const mockCipher = {
|
||||
id: "cipher-1",
|
||||
reprompt: 0,
|
||||
} as CipherViewLike;
|
||||
|
||||
it("navigates to assign-collections when reprompt is not required", async () => {
|
||||
await component.conditionallyNavigateToAssignCollections(mockCipher);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/assign-collections"], {
|
||||
queryParams: { cipherId: "cipher-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts for password when reprompt is required", async () => {
|
||||
const cipherWithReprompt = { ...mockCipher, reprompt: 1 };
|
||||
|
||||
await component.conditionallyNavigateToAssignCollections(
|
||||
cipherWithReprompt as CipherViewLike,
|
||||
);
|
||||
|
||||
expect(showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(navigate).toHaveBeenCalledWith(["/assign-collections"], {
|
||||
queryParams: { cipherId: "cipher-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not navigate when password prompt is cancelled", async () => {
|
||||
const cipherWithReprompt = { ...mockCipher, reprompt: 1 };
|
||||
showPasswordPrompt.mockResolvedValueOnce(false);
|
||||
|
||||
await component.conditionallyNavigateToAssignCollections(
|
||||
cipherWithReprompt as CipherViewLike,
|
||||
);
|
||||
|
||||
expect(showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
|
||||
|
||||
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 { 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";
|
||||
@@ -71,6 +73,9 @@ export class ArchiveComponent {
|
||||
private i18nService = inject(I18nService);
|
||||
private cipherArchiveService = inject(CipherArchiveService);
|
||||
private passwordRepromptService = inject(PasswordRepromptService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private collectionService = inject(CollectionService);
|
||||
|
||||
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
protected archivedCiphers$ = this.userId$.pipe(
|
||||
@@ -87,6 +92,20 @@ export class ArchiveComponent {
|
||||
startWith(true),
|
||||
);
|
||||
|
||||
protected canAssignCollections$ = this.userId$.pipe(
|
||||
switchMap((userId) => {
|
||||
return combineLatest([
|
||||
this.organizationService.hasOrganizations(userId),
|
||||
this.collectionService.decryptedCollections$(userId),
|
||||
]).pipe(
|
||||
map(([hasOrgs, collections]) => {
|
||||
const canEditCollections = collections.some((c) => !c.readOnly);
|
||||
return hasOrgs && canEditCollections;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
|
||||
switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)),
|
||||
);
|
||||
@@ -187,6 +206,17 @@ export class ArchiveComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompts for password when necessary then navigates to the assign collections route */
|
||||
async conditionallyNavigateToAssignCollections(cipher: CipherViewLike) {
|
||||
if (cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(["/assign-collections"], {
|
||||
queryParams: { cipherId: cipher.id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is able to interact with the cipher
|
||||
* (password re-prompt / decryption failure checks).
|
||||
|
||||
@@ -142,4 +142,45 @@ describe("VaultCipherRowComponent", () => {
|
||||
expect(overlayContent).not.toContain('appcopyfield="password"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("showAssignToCollections", () => {
|
||||
let archivedCipher: CipherView;
|
||||
|
||||
beforeEach(() => {
|
||||
archivedCipher = new CipherView();
|
||||
archivedCipher.id = "cipher-1";
|
||||
archivedCipher.name = "Test Cipher";
|
||||
archivedCipher.type = CipherType.Login;
|
||||
archivedCipher.organizationId = "org-1";
|
||||
archivedCipher.deletedDate = null;
|
||||
archivedCipher.archivedDate = new Date();
|
||||
|
||||
component.cipher = archivedCipher;
|
||||
component.organizations = [{ id: "org-1" } as any];
|
||||
component.canAssignCollections = true;
|
||||
component.disabled = false;
|
||||
});
|
||||
|
||||
it("returns true when cipher is archived and conditions are met", () => {
|
||||
expect(component["showAssignToCollections"]).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when cipher is deleted", () => {
|
||||
archivedCipher.deletedDate = new Date();
|
||||
|
||||
expect(component["showAssignToCollections"]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user cannot assign collections", () => {
|
||||
component.canAssignCollections = false;
|
||||
|
||||
expect(component["showAssignToCollections"]).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there are no organizations", () => {
|
||||
component.organizations = [];
|
||||
|
||||
expect(component["showAssignToCollections"]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,11 +217,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.decryptionFailure(this.cipher);
|
||||
}
|
||||
|
||||
// Do Not show Assign to Collections option if item is archived
|
||||
protected get showAssignToCollections() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.organizations?.length &&
|
||||
this.canAssignCollections &&
|
||||
|
||||
Reference in New Issue
Block a user