From de6b58c10ab6e24adbc6bbb8ae625107e4d86ff0 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 1 May 2025 12:44:08 -0500 Subject: [PATCH] [PM-20110] Disabled copy buttons on vault (#14549) * export BitIconButtonComponent from component library * manually update the disabled state of the icon button for copy cipher field directive * add tests for `CopyCipherFieldDirective` --- libs/components/src/icon-button/index.ts | 1 + .../copy-cipher-field.directive.spec.ts | 197 ++++++++++++++++++ .../components/copy-cipher-field.directive.ts | 8 +- 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 libs/vault/src/components/copy-cipher-field.directive.spec.ts diff --git a/libs/components/src/icon-button/index.ts b/libs/components/src/icon-button/index.ts index 9da4a3162b..cc52f26325 100644 --- a/libs/components/src/icon-button/index.ts +++ b/libs/components/src/icon-button/index.ts @@ -1 +1,2 @@ export * from "./icon-button.module"; +export { BitIconButtonComponent } from "./icon-button.component"; diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts new file mode 100644 index 0000000000..0847e7147a --- /dev/null +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -0,0 +1,197 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components"; +import { CopyCipherFieldService } from "@bitwarden/vault"; + +import { CopyCipherFieldDirective } from "./copy-cipher-field.directive"; + +describe("CopyCipherFieldDirective", () => { + const copyFieldService = { + copy: jest.fn().mockResolvedValue(null), + totpAllowed: jest.fn().mockResolvedValue(true), + }; + + let copyCipherFieldDirective: CopyCipherFieldDirective; + + beforeEach(() => { + copyFieldService.copy.mockClear(); + copyFieldService.totpAllowed.mockClear(); + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + ); + copyCipherFieldDirective.cipher = new CipherView(); + }); + + describe("disabled state", () => { + it("should be enabled when the field is available", async () => { + copyCipherFieldDirective.action = "username"; + copyCipherFieldDirective.cipher.login.username = "test-username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(null); + }); + + it("should be disabled when the field is not available", async () => { + // create empty cipher + copyCipherFieldDirective.cipher = new CipherView(); + + copyCipherFieldDirective.action = "username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(true); + }); + + it("updates icon button disabled state", async () => { + const iconButton = { + disabled: { + set: jest.fn(), + }, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + undefined, + iconButton as unknown as BitIconButtonComponent, + ); + + copyCipherFieldDirective.action = "password"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(iconButton.disabled.set).toHaveBeenCalledWith(true); + }); + + it("updates menuItemDirective disabled state", async () => { + const menuItemDirective = { + disabled: false, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + menuItemDirective as unknown as MenuItemDirective, + ); + + copyCipherFieldDirective.action = "totp"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(menuItemDirective.disabled).toBe(true); + }); + }); + + describe("login", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.login.username = "test-username"; + copyCipherFieldDirective.cipher.login.password = "test-password"; + copyCipherFieldDirective.cipher.login.totp = "test-totp"; + }); + + it.each([ + ["username", "test-username"], + ["password", "test-password"], + ["totp", "test-totp"], + ])("copies %s field from login to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("identity", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.identity.username = "test-username"; + copyCipherFieldDirective.cipher.identity.email = "test-email"; + copyCipherFieldDirective.cipher.identity.phone = "test-phone"; + copyCipherFieldDirective.cipher.identity.address1 = "test-address-1"; + }); + + it.each([ + ["username", "test-username"], + ["email", "test-email"], + ["phone", "test-phone"], + ["address", "test-address-1"], + ])("copies %s field from identity to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("card", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.card.number = "test-card-number"; + copyCipherFieldDirective.cipher.card.code = "test-card-code"; + }); + + it.each([ + ["cardNumber", "test-card-number"], + ["securityCode", "test-card-code"], + ])("copies %s field from card to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("secure note", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.notes = "test-secure-note"; + }); + + it("copies secure note field to clipboard", async () => { + copyCipherFieldDirective.action = "secureNote"; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + "test-secure-note", + "secureNote", + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("ssh key", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key"; + copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key"; + copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint"; + }); + + it.each([ + ["privateKey", "test-private-key"], + ["publicKey", "test-public-key"], + ["keyFingerprint", "test-key-fingerprint"], + ])("copies %s field from ssh key to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); +}); diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 1eb96a3044..324b43f12d 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,7 +1,7 @@ import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MenuItemDirective } from "@bitwarden/components"; +import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; /** @@ -33,6 +33,7 @@ export class CopyCipherFieldDirective implements OnChanges { constructor( private copyCipherFieldService: CopyCipherFieldService, @Optional() private menuItemDirective?: MenuItemDirective, + @Optional() private iconButtonComponent?: BitIconButtonComponent, ) {} @HostBinding("attr.disabled") @@ -65,6 +66,11 @@ export class CopyCipherFieldDirective implements OnChanges { ? true : null; + // When used on an icon button, update the disabled state of the button component + if (this.iconButtonComponent) { + this.iconButtonComponent.disabled.set(this.disabled ?? false); + } + // 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;