1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 00:03:56 +00:00

[PM-22134] Migrate list views to CipherListView from the SDK (#15174)

* add `CipherViewLike` and utilities to handle `CipherView` and `CipherViewLike`

* migrate libs needed for web vault to support `CipherViewLike`

* migrate web vault components to support

* add  for CipherView.  will have to be later

* fetch full CipherView for copying a password

* have only the cipher service utilize SDK migration flag

- This keeps feature flag logic away from the component
- Also cuts down on what is needed for other platforms

* strongly type CipherView for AC vault

- Probably temporary before migration of the AC vault to `CipherListView` SDK

* fix build icon tests by being more gracious with the uri structure

* migrate desktop components to CipherListViews$

* consume card from sdk

* add browser implementation for `CipherListView`

* update copy message for single copiable items

* refactor `getCipherViewLikeLogin` to `getLogin`

* refactor `getCipherViewLikeCard` to `getCard`

* add `hasFido2Credentials` helper

* add decryption failure to cipher like utils

* add todo with ticket

* fix decryption failure typing

* fix copy card messages

* fix addition of organizations and collections for `PopupCipherViewLike`

- accessors were being lost

* refactor to getters to fix re-rendering bug

* fix decryption failure helper

* fix sorting functions for `CipherViewLike`

* formatting

* add `CipherViewLikeUtils` tests

* refactor "copiable" to "copyable" to match SDK

* use `hasOldAttachments` from cipherlistview

* fix typing

* update SDK version

* add feature flag for cipher list view work

* use `CipherViewLikeUtils` for copyable values rather than referring to the cipher directly

* update restricted item type to support CipherViewLike

* add cipher support to `CipherViewLikeUtils`

* update `isCipherListView` check

* refactor CipherLike to a separate type

* refactor `getFullCipherView` into the cipher service

* add optional chaining for `uriChecksum`

* set empty array for decrypted CipherListView

* migrate nudge service to use `cipherListViews`

* update web vault to not depend on `cipherViews$`

* update popup list filters to use `CipherListView`

* fix storybook

* fix tests

* accept undefined as a MY VAULT filter value for cipher list views

* use `LoginUriView` for uri logic (#15530)

* filter out null ciphers from the `_allDecryptedCiphers$` (#15539)

* use `launchUri` to avoid any unexpected behavior in URIs - this appends `http://` when missing
This commit is contained in:
Nick Krantz
2025-07-17 14:55:32 -05:00
committed by GitHub
parent 00b6b0224e
commit b4120e0e3f
54 changed files with 1907 additions and 514 deletions

View File

@@ -1,3 +1,9 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components";
import { CopyCipherFieldService } from "@bitwarden/vault";
@@ -9,23 +15,31 @@ describe("CopyCipherFieldDirective", () => {
copy: jest.fn().mockResolvedValue(null),
totpAllowed: jest.fn().mockResolvedValue(true),
};
let mockAccountService: AccountService;
let mockCipherService: CipherService;
let copyCipherFieldDirective: CopyCipherFieldDirective;
beforeEach(() => {
copyFieldService.copy.mockClear();
copyFieldService.totpAllowed.mockClear();
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = of({ id: "test-account-id" } as Account);
mockCipherService = mock<CipherService>();
copyCipherFieldDirective = new CopyCipherFieldDirective(
copyFieldService as unknown as CopyCipherFieldService,
mockAccountService,
mockCipherService,
);
copyCipherFieldDirective.cipher = new CipherView();
copyCipherFieldDirective.cipher.type = CipherType.Login;
});
describe("disabled state", () => {
it("should be enabled when the field is available", async () => {
copyCipherFieldDirective.action = "username";
copyCipherFieldDirective.cipher.login.username = "test-username";
(copyCipherFieldDirective.cipher as CipherView).login.username = "test-username";
await copyCipherFieldDirective.ngOnChanges();
@@ -35,6 +49,7 @@ describe("CopyCipherFieldDirective", () => {
it("should be disabled when the field is not available", async () => {
// create empty cipher
copyCipherFieldDirective.cipher = new CipherView();
copyCipherFieldDirective.cipher.type = CipherType.Login;
copyCipherFieldDirective.action = "username";
@@ -52,11 +67,15 @@ describe("CopyCipherFieldDirective", () => {
copyCipherFieldDirective = new CopyCipherFieldDirective(
copyFieldService as unknown as CopyCipherFieldService,
mockAccountService,
mockCipherService,
undefined,
iconButton as unknown as BitIconButtonComponent,
);
copyCipherFieldDirective.action = "password";
copyCipherFieldDirective.cipher = new CipherView();
copyCipherFieldDirective.cipher.type = CipherType.Login;
await copyCipherFieldDirective.ngOnChanges();
@@ -70,6 +89,8 @@ describe("CopyCipherFieldDirective", () => {
copyCipherFieldDirective = new CopyCipherFieldDirective(
copyFieldService as unknown as CopyCipherFieldService,
mockAccountService,
mockCipherService,
menuItemDirective as unknown as MenuItemDirective,
);
@@ -83,9 +104,11 @@ describe("CopyCipherFieldDirective", () => {
describe("login", () => {
beforeEach(() => {
copyCipherFieldDirective.cipher.login.username = "test-username";
copyCipherFieldDirective.cipher.login.password = "test-password";
copyCipherFieldDirective.cipher.login.totp = "test-totp";
const cipher = copyCipherFieldDirective.cipher as CipherView;
cipher.type = CipherType.Login;
cipher.login.username = "test-username";
cipher.login.password = "test-password";
cipher.login.totp = "test-totp";
});
it.each([
@@ -107,10 +130,12 @@ describe("CopyCipherFieldDirective", () => {
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";
const cipher = copyCipherFieldDirective.cipher as CipherView;
cipher.type = CipherType.Identity;
cipher.identity.username = "test-username";
cipher.identity.email = "test-email";
cipher.identity.phone = "test-phone";
cipher.identity.address1 = "test-address-1";
});
it.each([
@@ -133,8 +158,10 @@ describe("CopyCipherFieldDirective", () => {
describe("card", () => {
beforeEach(() => {
copyCipherFieldDirective.cipher.card.number = "test-card-number";
copyCipherFieldDirective.cipher.card.code = "test-card-code";
const cipher = copyCipherFieldDirective.cipher as CipherView;
cipher.type = CipherType.Card;
cipher.card.number = "test-card-number";
cipher.card.code = "test-card-code";
});
it.each([
@@ -155,7 +182,9 @@ describe("CopyCipherFieldDirective", () => {
describe("secure note", () => {
beforeEach(() => {
copyCipherFieldDirective.cipher.notes = "test-secure-note";
const cipher = copyCipherFieldDirective.cipher as CipherView;
cipher.type = CipherType.SecureNote;
cipher.notes = "test-secure-note";
});
it("copies secure note field to clipboard", async () => {
@@ -173,9 +202,11 @@ describe("CopyCipherFieldDirective", () => {
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";
const cipher = copyCipherFieldDirective.cipher as CipherView;
cipher.type = CipherType.SshKey;
cipher.sshKey.privateKey = "test-private-key";
cipher.sshKey.publicKey = "test-public-key";
cipher.sshKey.keyFingerprint = "test-key-fingerprint";
});
it.each([

View File

@@ -1,6 +1,14 @@
import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
@@ -27,10 +35,12 @@ export class CopyCipherFieldDirective implements OnChanges {
})
action!: Exclude<CopyAction, "hiddenField">;
@Input({ required: true }) cipher!: CipherView;
@Input({ required: true }) cipher!: CipherViewLike;
constructor(
private copyCipherFieldService: CopyCipherFieldService,
private accountService: AccountService,
private cipherService: CipherService,
@Optional() private menuItemDirective?: MenuItemDirective,
@Optional() private iconButtonComponent?: BitIconButtonComponent,
) {}
@@ -49,7 +59,7 @@ export class CopyCipherFieldDirective implements OnChanges {
@HostListener("click")
async copy() {
const value = this.getValueToCopy();
const value = await this.getValueToCopy();
await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher);
}
@@ -60,7 +70,7 @@ export class CopyCipherFieldDirective implements OnChanges {
private async updateDisabledState() {
this.disabled =
!this.cipher ||
!this.getValueToCopy() ||
!this.hasValueToCopy() ||
(this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher)))
? true
: null;
@@ -76,32 +86,51 @@ export class CopyCipherFieldDirective implements OnChanges {
}
}
private getValueToCopy() {
/** Returns `true` when the cipher has the associated value as populated. */
private hasValueToCopy() {
return CipherViewLikeUtils.hasCopyableValue(this.cipher, this.action);
}
/** Returns the value of the cipher to be copied. */
private async getValueToCopy() {
let _cipher: CipherView;
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
// When the cipher is of type `CipherListView`, the full cipher needs to be decrypted
const activeAccountId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const encryptedCipher = await this.cipherService.get(this.cipher.id!, activeAccountId);
_cipher = await this.cipherService.decrypt(encryptedCipher, activeAccountId);
} else {
_cipher = this.cipher;
}
switch (this.action) {
case "username":
return this.cipher.login?.username || this.cipher.identity?.username;
return _cipher.login?.username || _cipher.identity?.username;
case "password":
return this.cipher.login?.password;
return _cipher.login?.password;
case "totp":
return this.cipher.login?.totp;
return _cipher.login?.totp;
case "cardNumber":
return this.cipher.card?.number;
return _cipher.card?.number;
case "securityCode":
return this.cipher.card?.code;
return _cipher.card?.code;
case "email":
return this.cipher.identity?.email;
return _cipher.identity?.email;
case "phone":
return this.cipher.identity?.phone;
return _cipher.identity?.phone;
case "address":
return this.cipher.identity?.fullAddressForCopy;
return _cipher.identity?.fullAddressForCopy;
case "secureNote":
return this.cipher.notes;
return _cipher.notes;
case "privateKey":
return this.cipher.sshKey?.privateKey;
return _cipher.sshKey?.privateKey;
case "publicKey":
return this.cipher.sshKey?.publicKey;
return _cipher.sshKey?.publicKey;
case "keyFingerprint":
return this.cipher.sshKey?.keyFingerprint;
return _cipher.sshKey?.keyFingerprint;
default:
return null;
}

View File

@@ -8,7 +8,7 @@ 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 { CipherRepromptType, CipherType } 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";
@@ -128,6 +128,7 @@ describe("CopyCipherFieldService", () => {
describe("totp", () => {
beforeEach(() => {
actionType = "totp";
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.totp = "secret-totp";
cipher.reprompt = CipherRepromptType.None;

View File

@@ -9,7 +9,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -103,7 +106,7 @@ export class CopyCipherFieldService {
async copy(
valueToCopy: string,
actionType: CopyAction,
cipher: CipherView,
cipher: CipherViewLike,
skipReprompt: boolean = false,
): Promise<boolean> {
const action = CopyActions[actionType];
@@ -153,13 +156,16 @@ export class CopyCipherFieldService {
/**
* Determines if TOTP generation is allowed for a cipher and user.
*/
async totpAllowed(cipher: CipherView): Promise<boolean> {
async totpAllowed(cipher: CipherViewLike): Promise<boolean> {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount?.id) {
return false;
}
const login = CipherViewLikeUtils.getLogin(cipher);
return (
(cipher?.login?.hasTotp ?? false) &&
!!login?.totp &&
(cipher.organizationUseTotp ||
(await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeAccount.id),

View File

@@ -4,7 +4,7 @@ import { firstValueFrom, lastValueFrom } from "rxjs";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptComponent } from "../components/password-reprompt.component";
@@ -28,7 +28,7 @@ export class PasswordRepromptService {
return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"];
}
async passwordRepromptCheck(cipher: CipherView) {
async passwordRepromptCheck(cipher: CipherViewLike) {
if (cipher.reprompt === CipherRepromptType.None) {
return true;
}