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

[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
This commit is contained in:
Shane Melton
2024-06-04 14:09:09 -07:00
committed by GitHub
parent fc2953a126
commit d1a9d6f613
10 changed files with 580 additions and 12 deletions

View File

@@ -142,6 +142,17 @@ export class IdentityView extends ItemView {
return addressPart2;
}
get fullAddressForCopy(): string {
let address = this.fullAddress;
if (this.city != null || this.state != null || this.postalCode != null) {
address += "\n" + this.fullAddressPart2;
}
if (this.country != null) {
address += "\n" + this.country;
}
return address;
}
static fromJSON(obj: Partial<Jsonify<IdentityView>>): IdentityView {
return Object.assign(new IdentityView(), obj);
}

View File

@@ -0,0 +1,78 @@
import { Directive, HostBinding, HostListener, Input, OnChanges } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
/**
* Directive to copy a specific field from a cipher on click. Uses the `CopyCipherFieldService` to
* handle the copying of the field and any necessary password re-prompting or totp generation.
*
* Automatically disables the host element if the field to copy is not available or null.
*
* @example
* ```html
* <button appCopyField="username" [cipher]="cipher">Copy Username</button>
* ```
*/
@Directive({
standalone: true,
selector: "[appCopyField]",
})
export class CopyCipherFieldDirective implements OnChanges {
@Input({
alias: "appCopyField",
required: true,
})
action: Exclude<CopyAction, "hiddenField">;
@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;
}
}
}

View File

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

View File

@@ -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<PlatformUtilsService>;
let toastService: MockProxy<ToastService>;
let eventCollectionService: MockProxy<EventCollectionService>;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let totpService: MockProxy<TotpService>;
let i18nService: MockProxy<I18nService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
beforeEach(() => {
platformUtilsService = mock<PlatformUtilsService>();
toastService = mock<ToastService>();
eventCollectionService = mock<EventCollectionService>();
passwordRepromptService = mock<PasswordRepromptService>();
totpService = mock<TotpService>();
i18nService = mock<I18nService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
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<CipherView>();
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,
);
});
});
});

View File

@@ -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<CopyAction, CopyActionInfo> = {
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<boolean> {
return (
(cipher?.login?.hasTotp ?? false) &&
(cipher.organizationUseTotp ||
(await firstValueFrom(this.billingAccountProfileStateService.hasPremiumFromAnySource$)))
);
}
}