1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[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`
This commit is contained in:
Nick Krantz
2025-05-01 12:44:08 -05:00
committed by GitHub
parent 1123a5993e
commit de6b58c10a
3 changed files with 205 additions and 1 deletions

View File

@@ -1 +1,2 @@
export * from "./icon-button.module"; export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component";

View File

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

View File

@@ -1,7 +1,7 @@
import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; 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"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
/** /**
@@ -33,6 +33,7 @@ export class CopyCipherFieldDirective implements OnChanges {
constructor( constructor(
private copyCipherFieldService: CopyCipherFieldService, private copyCipherFieldService: CopyCipherFieldService,
@Optional() private menuItemDirective?: MenuItemDirective, @Optional() private menuItemDirective?: MenuItemDirective,
@Optional() private iconButtonComponent?: BitIconButtonComponent,
) {} ) {}
@HostBinding("attr.disabled") @HostBinding("attr.disabled")
@@ -65,6 +66,11 @@ export class CopyCipherFieldDirective implements OnChanges {
? true ? true
: null; : 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 the directive is used on a menu item, update the menu item to prevent keyboard navigation
if (this.menuItemDirective) { if (this.menuItemDirective) {
this.menuItemDirective.disabled = this.disabled ?? false; this.menuItemDirective.disabled = this.disabled ?? false;