From d1a9d6f6138c1a467c364cf8e52525f17ba3af4f Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 4 Jun 2024 14:09:09 -0700 Subject: [PATCH] [PM-8455] [PM-7683] Dynamic list items - Copy Action (#9410) * [PM-7683] Add fullAddressForCopy helper to identity.view * [PM-7683] Introduce CopyCipherFieldService to the Vault library - A new CopyCipherFieldService that can be used to copy a cipher's field to the user clipboard - A new appCopyField directive to make it easy to copy a cipher's fields in templates - Tests for the CopyCipherFieldService * [PM-7683] Introduce item-copy-actions.component * [PM-7683] Fix username value in copy cipher directive * [PM-7683] Add title to View item link * [PM-7683] Move disabled logic into own method --- apps/browser/src/_locales/en/messages.json | 65 ++++++- .../item-copy-actions.component.html | 77 ++++++++ .../item-copy-actions.component.ts | 29 +++ .../vault-list-items-container.component.html | 16 +- .../vault-list-items-container.component.ts | 2 + .../src/vault/models/view/identity.view.ts | 11 ++ .../components/copy-cipher-field.directive.ts | 78 ++++++++ libs/vault/src/index.ts | 2 + .../copy-cipher-field.service.spec.ts | 170 ++++++++++++++++++ .../src/services/copy-cipher-field.service.ts | 142 +++++++++++++++ 10 files changed, 580 insertions(+), 12 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts create mode 100644 libs/vault/src/components/copy-cipher-field.directive.ts create mode 100644 libs/vault/src/services/copy-cipher-field.service.spec.ts create mode 100644 libs/vault/src/services/copy-cipher-field.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index deb7410a71..3510ed10cb 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -185,7 +185,7 @@ "message": "Continue to browser extension store?" }, "continueToBrowserExtensionStoreDesc": { - "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." + "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." }, "changeMasterPasswordOnWebConfirmation": { "message": "You can change your master password on the Bitwarden web app." @@ -3281,7 +3281,7 @@ "clearFiltersOrTryAnother": { "message": "Clear filters or try another search term" }, - "copyInfo": { + "copyInfoLabel": { "message": "Copy info, $ITEMNAME$", "description": "Aria label for a button that opens a menu with options to copy information from an item.", "placeholders": { @@ -3291,7 +3291,37 @@ } } }, - "moreOptions": { + "copyInfoTitle": { + "message": "Copy info - $ITEMNAME$", + "description": "Title for a button that opens a menu with options to copy information from an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "copyNoteLabel": { + "message": "Copy Note, $ITEMNAME$", + "description": "Aria label for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "copyNoteTitle": { + "message": "Copy Note - $ITEMNAME$", + "description": "Title for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "moreOptionsLabel": { "message": "More options, $ITEMNAME$", "description": "Aria label for a button that opens a menu with more options for an item.", "placeholders": { @@ -3301,6 +3331,35 @@ } } }, + "moreOptionsTitle": { + "message": "More options - $ITEMNAME$", + "description": "Title for a button that opens a menu with more options for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "viewItemTitle": { + "message": "View item - $ITEMNAME$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "copyEmail": { + "message": "Copy email" + }, + "copyPhone": { + "message": "Copy phone" + }, + "copyAddress": { + "message": "Copy address" + }, "adminConsole": { "message": "Admin Console" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html new file mode 100644 index 0000000000..08133c6b46 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts new file mode 100644 index 0000000000..c89fcca3b3 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { CopyCipherFieldDirective } from "@bitwarden/vault"; + +@Component({ + standalone: true, + selector: "app-item-copy-actions", + templateUrl: "item-copy-actions.component.html", + imports: [ + ItemModule, + IconButtonModule, + JslibModule, + MenuModule, + CommonModule, + CopyCipherFieldDirective, + ], +}) +export class ItemCopyActionsComponent { + @Input() cipher: CipherView; + + protected CipherType = CipherType; + + constructor() {} +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index d3bb85c710..47725cf9dc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -13,7 +13,12 @@ - + {{ cipher.name }} {{ cipher.subTitle }} @@ -22,14 +27,7 @@ - - - + + * ``` + */ +@Directive({ + standalone: true, + selector: "[appCopyField]", +}) +export class CopyCipherFieldDirective implements OnChanges { + @Input({ + alias: "appCopyField", + required: true, + }) + action: Exclude; + + @Input({ required: true }) cipher: CipherView; + + constructor(private copyCipherFieldService: CopyCipherFieldService) {} + + @HostBinding("attr.disabled") + protected disabled: boolean | null = null; + + @HostListener("click") + async copy() { + const value = this.getValueToCopy(); + await this.copyCipherFieldService.copy(value, this.action, this.cipher); + } + + async ngOnChanges() { + await this.updateDisabledState(); + } + + private async updateDisabledState() { + this.disabled = + !this.cipher || + !this.getValueToCopy() || + (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) + ? true + : null; + } + + private getValueToCopy() { + switch (this.action) { + case "username": + return this.cipher.login?.username || this.cipher.identity?.username; + case "password": + return this.cipher.login?.password; + case "totp": + return this.cipher.login?.totp; + case "cardNumber": + return this.cipher.card?.number; + case "securityCode": + return this.cipher.card?.code; + case "email": + return this.cipher.identity?.email; + case "phone": + return this.cipher.identity?.phone; + case "address": + return this.cipher.identity?.fullAddressForCopy; + case "secureNote": + return this.cipher.notes; + default: + return null; + } + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index bf6b7f91c2..00fa042080 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -1 +1,3 @@ export { PasswordRepromptService } from "./services/password-reprompt.service"; +export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; +export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts new file mode 100644 index 0000000000..57c77ebb0b --- /dev/null +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -0,0 +1,170 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { ToastService } from "@bitwarden/components"; +import { CopyAction, CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; + +describe("CopyCipherFieldService", () => { + let service: CopyCipherFieldService; + let platformUtilsService: MockProxy; + let toastService: MockProxy; + let eventCollectionService: MockProxy; + let passwordRepromptService: MockProxy; + let totpService: MockProxy; + let i18nService: MockProxy; + let billingAccountProfileStateService: MockProxy; + + beforeEach(() => { + platformUtilsService = mock(); + toastService = mock(); + eventCollectionService = mock(); + passwordRepromptService = mock(); + totpService = mock(); + i18nService = mock(); + billingAccountProfileStateService = mock(); + + service = new CopyCipherFieldService( + platformUtilsService, + toastService, + eventCollectionService, + passwordRepromptService, + totpService, + i18nService, + billingAccountProfileStateService, + ); + }); + + describe("copy", () => { + let cipher: CipherView; + let valueToCopy: string; + let actionType: CopyAction; + let skipReprompt: boolean; + + beforeEach(() => { + cipher = mock(); + valueToCopy = "test"; + actionType = "username"; + skipReprompt = false; + }); + + it("should return early when valueToCopy is null", async () => { + valueToCopy = null; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should return early when cipher.viewPassword is false", async () => { + cipher.viewPassword = false; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should copy value to clipboard", async () => { + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith(valueToCopy); + }); + + it("should show a success toast on copy", async () => { + i18nService.t.mockReturnValueOnce("Username").mockReturnValueOnce("Username copied"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "Username copied", + title: null, + }); + expect(i18nService.t).toHaveBeenCalledWith("username"); + expect(i18nService.t).toHaveBeenCalledWith("valueCopied", "Username"); + }); + + describe("password reprompt", () => { + beforeEach(() => { + actionType = "password"; + cipher.reprompt = CipherRepromptType.Password; + }); + + it("should show password prompt when actionType requires it", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + }); + + it("should skip password prompt when cipher.reprompt is 'None'", async () => { + cipher.reprompt = CipherRepromptType.None; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalled(); + }); + + it("should skip password prompt when skipReprompt is true", async () => { + skipReprompt = true; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(passwordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + }); + + it("should return early when password prompt is not confirmed", async () => { + passwordRepromptService.showPasswordPrompt.mockResolvedValue(false); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + }); + + describe("totp", () => { + beforeEach(() => { + actionType = "totp"; + cipher.login = new LoginView(); + cipher.login.totp = "secret-totp"; + cipher.reprompt = CipherRepromptType.None; + cipher.organizationUseTotp = false; + }); + + it("should get TOTP code when allowed from premium", async () => { + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true); + totpService.getCode.mockResolvedValue("123456"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); + }); + + it("should get TOTP code when allowed from organization", async () => { + cipher.organizationUseTotp = true; + totpService.getCode.mockResolvedValue("123456"); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).toHaveBeenCalledWith(valueToCopy); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith("123456"); + }); + + it("should return early when the user is not allowed to use TOTP", async () => { + billingAccountProfileStateService.hasPremiumFromAnySource$ = of(false); + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + + it("should return early when TOTP is not set", async () => { + cipher.login.totp = null; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).not.toHaveBeenCalled(); + }); + }); + + it("should collect an event when actionType has one", async () => { + actionType = "password"; + skipReprompt = true; + await service.copy(valueToCopy, actionType, cipher, skipReprompt); + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.Cipher_ClientCopiedPassword, + cipher.id, + ); + }); + }); +}); diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts new file mode 100644 index 0000000000..9cd94f5ce6 --- /dev/null +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -0,0 +1,142 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +/** + * The types of fields that can be copied from a cipher. + */ +export type CopyAction = + | "username" + | "password" + | "totp" + | "cardNumber" + | "securityCode" + | "email" + | "phone" + | "address" + | "secureNote" + | "hiddenField"; + +type CopyActionInfo = { + /** + * The i18n key for the type of field being copied. Will be used to display a toast message. + */ + typeI18nKey: string; + + /** + * Whether the field is protected and requires password re-prompting before being copied. + */ + protected: boolean; + + /** + * Optional event to collect when the field is copied. + */ + event?: EventType; +}; + +const CopyActions: Record = { + username: { typeI18nKey: "username", protected: false }, + password: { + typeI18nKey: "password", + protected: true, + event: EventType.Cipher_ClientCopiedPassword, + }, + totp: { typeI18nKey: "verificationCodeTotp", protected: true }, + cardNumber: { typeI18nKey: "number", protected: true }, + securityCode: { + typeI18nKey: "securityCode", + protected: true, + event: EventType.Cipher_ClientCopiedCardCode, + }, + email: { typeI18nKey: "email", protected: false }, + phone: { typeI18nKey: "phone", protected: false }, + address: { typeI18nKey: "address", protected: false }, + secureNote: { typeI18nKey: "note", protected: false }, + hiddenField: { + typeI18nKey: "value", + protected: true, + event: EventType.Cipher_ClientCopiedHiddenField, + }, +}; + +@Injectable({ + providedIn: "root", +}) +export class CopyCipherFieldService { + constructor( + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + private eventCollectionService: EventCollectionService, + private passwordRepromptService: PasswordRepromptService, + private totpService: TotpService, + private i18nService: I18nService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) {} + + /** + * Copy a field value from a cipher to the clipboard. + * @param valueToCopy The value to copy. + * @param actionType The type of field being copied. + * @param cipher The cipher containing the field to copy. + * @param skipReprompt Whether to skip password re-prompting. + */ + async copy( + valueToCopy: string, + actionType: CopyAction, + cipher: CipherView, + skipReprompt: boolean = false, + ) { + const action = CopyActions[actionType]; + if ( + !skipReprompt && + cipher.reprompt !== CipherRepromptType.None && + action.protected && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return; + } + + if (valueToCopy == null || !cipher.viewPassword) { + return; + } + + if (actionType === "totp") { + if (!(await this.totpAllowed(cipher))) { + return; + } + valueToCopy = await this.totpService.getCode(valueToCopy); + } + + this.platformUtilsService.copyToClipboard(valueToCopy); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t(action.typeI18nKey)), + title: null, + }); + + if (action.event !== undefined) { + await this.eventCollectionService.collect(action.event, cipher.id); + } + } + + /** + * Determines if TOTP generation is allowed for a cipher and user. + */ + async totpAllowed(cipher: CipherView): Promise { + return ( + (cipher?.login?.hasTotp ?? false) && + (cipher.organizationUseTotp || + (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$))) + ); + } +}