[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:
parent
fc2953a126
commit
d1a9d6f613
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
<bit-item-action *ngIf="cipher.type === CipherType.Login">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
|
||||
[title]="'copyInfoTitle' | i18n: cipher.name"
|
||||
[bitMenuTriggerFor]="loginOptions"
|
||||
></button>
|
||||
<bit-menu #loginOptions>
|
||||
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
|
||||
{{ "copyUsername" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="password" [cipher]="cipher">
|
||||
{{ "copyPassword" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="totp" [cipher]="cipher">
|
||||
{{ "copyVerificationCode" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</bit-item-action>
|
||||
|
||||
<bit-item-action *ngIf="cipher.type === CipherType.Card">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
|
||||
[title]="'copyInfoTitle' | i18n: cipher.name"
|
||||
[bitMenuTriggerFor]="cardOptions"
|
||||
></button>
|
||||
<bit-menu #cardOptions>
|
||||
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher">
|
||||
{{ "copyNumber" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher">
|
||||
{{ "copySecurityCode" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</bit-item-action>
|
||||
|
||||
<bit-item-action *ngIf="cipher.type === CipherType.Identity">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
|
||||
[title]="'copyInfoTitle' | i18n: cipher.name"
|
||||
[bitMenuTriggerFor]="identityOptions"
|
||||
></button>
|
||||
<bit-menu #identityOptions>
|
||||
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
|
||||
{{ "copyUsername" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher">
|
||||
{{ "copyEmail" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher">
|
||||
{{ "copyPhone" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher">
|
||||
{{ "copyAddress" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</bit-item-action>
|
||||
|
||||
<bit-item-action *ngIf="cipher.type === CipherType.SecureNote">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyNoteLabel' | i18n: cipher.name"
|
||||
[title]="'copyNoteTitle' | i18n: cipher.name"
|
||||
appCopyField="secureNote"
|
||||
[cipher]="cipher"
|
||||
></button>
|
||||
</bit-item-action>
|
|
@ -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() {}
|
||||
}
|
|
@ -13,7 +13,12 @@
|
|||
</popup-section-header>
|
||||
<bit-item-group>
|
||||
<bit-item *ngFor="let cipher of ciphers">
|
||||
<a bit-item-content [routerLink]="['/view-cipher']" [queryParams]="{ cipherId: cipher.id }">
|
||||
<a
|
||||
bit-item-content
|
||||
[routerLink]="['/view-cipher']"
|
||||
[queryParams]="{ cipherId: cipher.id }"
|
||||
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
|
||||
>
|
||||
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
|
||||
{{ cipher.name }}
|
||||
<span slot="secondary">{{ cipher.subTitle }}</span>
|
||||
|
@ -22,14 +27,7 @@
|
|||
<bit-item-action *ngIf="showAutoFill">
|
||||
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[attr.aria-label]="'copyInfo' | i18n: cipher.name"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
|
||||
<bit-item-action>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@bitwarden/components";
|
||||
|
||||
import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component";
|
||||
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
|
@ -27,6 +28,7 @@ import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup
|
|||
JslibModule,
|
||||
PopupSectionHeaderComponent,
|
||||
RouterLink,
|
||||
ItemCopyActionsComponent,
|
||||
],
|
||||
selector: "app-vault-list-items-container",
|
||||
templateUrl: "vault-list-items-container.component.html",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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$)))
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue