From b4120e0e3f83e5cbbe9ecb258c9a67d364475158 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:55:32 -0500 Subject: [PATCH] [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 --- apps/browser/src/_locales/en/messages.json | 11 +- .../autofill-vault-list-items.component.ts | 9 +- .../item-copy-actions.component.html | 40 +- .../item-copy-actions.component.ts | 161 +++-- .../item-more-options.component.html | 2 +- .../item-more-options.component.ts | 37 +- .../vault-list-items-container.component.html | 9 +- .../vault-list-items-container.component.ts | 60 +- .../vault-popup-items.service.spec.ts | 41 +- .../services/vault-popup-items.service.ts | 126 ++-- .../vault-popup-list-filters.service.spec.ts | 37 +- .../vault-popup-list-filters.service.ts | 79 ++- .../trash-list-items-container.component.ts | 12 +- .../vault/popup/views/popup-cipher.view.ts | 28 +- apps/desktop/src/locales/en/messages.json | 17 + .../app/vault/vault-items-v2.component.html | 6 +- .../app/vault/vault-items-v2.component.ts | 12 +- .../src/vault/app/vault/vault-v2.component.ts | 21 +- .../collections/vault.component.ts | 4 +- .../vault-item-dialog.component.ts | 3 +- .../vault-cipher-row.component.html | 29 +- .../vault-items/vault-cipher-row.component.ts | 74 ++- .../vault-collection-row.component.ts | 5 +- .../vault-items/vault-item-event.ts | 20 +- .../components/vault-items/vault-item.ts | 6 +- .../vault-items/vault-items.component.ts | 61 +- .../vault-items/vault-items.stories.ts | 3 +- .../services/vault-filter.service.spec.ts | 2 +- .../services/vault-filter.service.ts | 5 +- .../shared/models/filter-function.spec.ts | 2 +- .../shared/models/filter-function.ts | 26 +- .../vault-onboarding.component.ts | 4 +- .../vault/individual-vault/vault.component.ts | 111 +++- apps/web/src/locales/en/messages.json | 17 + .../src/vault/components/icon.component.ts | 4 +- .../vault/components/vault-items.component.ts | 38 +- .../empty-vault-nudge.service.ts | 2 +- .../vault-filter/models/vault-filter.model.ts | 13 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/vault/abstractions/cipher.service.ts | 21 +- .../src/vault/abstractions/search.service.ts | 15 +- .../src/vault/icon/build-cipher-icon.ts | 18 +- .../services/cipher-authorization.service.ts | 12 +- .../src/vault/services/cipher.service.ts | 100 ++- .../services/restricted-item-types.service.ts | 10 +- .../src/vault/services/search.service.ts | 31 +- libs/common/src/vault/types/cipher-like.ts | 9 + .../utils/cipher-view-like-utils.spec.ts | 624 ++++++++++++++++++ .../src/vault/utils/cipher-view-like-utils.ts | 301 +++++++++ .../copy-cipher-field.directive.spec.ts | 59 +- .../components/copy-cipher-field.directive.ts | 61 +- .../copy-cipher-field.service.spec.ts | 3 +- .../src/services/copy-cipher-field.service.ts | 14 +- .../src/services/password-reprompt.service.ts | 4 +- 54 files changed, 1907 insertions(+), 514 deletions(-) create mode 100644 libs/common/src/vault/types/cipher-like.ts create mode 100644 libs/common/src/vault/utils/cipher-view-like-utils.spec.ts create mode 100644 libs/common/src/vault/utils/cipher-view-like-utils.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 11d13392ce..7b1262627b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -4563,17 +4566,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index aa790d24ed..1eef907821 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -6,12 +6,13 @@ import { combineLatest, map, Observable, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { IconButtonModule, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; -import { PopupCipherView } from "../../../views/popup-cipher.view"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -30,7 +31,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** @@ -62,7 +63,9 @@ export class AutofillVaultListItemsComponent { ]).pipe( map( ([hasFilter, ciphers, canAutoFill]) => - !hasFilter && canAutoFill && ciphers.filter((c) => c.type == CipherType.Login).length === 0, + !hasFilter && + canAutoFill && + ciphers.filter((c) => CipherViewLikeUtils.getType(c) == CipherType.Login).length === 0, ), ); 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 index 576f6b7def..567d527745 100644 --- 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 @@ -1,4 +1,4 @@ - + - + - + - + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 75bc984e97..ce61e29e9e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -14,8 +14,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DialogService, IconButtonModule, @@ -34,12 +37,12 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject(undefined); @Input({ required: true, }) - set cipher(c: CipherView) { + set cipher(c: CipherViewLike) { this._cipher$.next(c); } @@ -109,17 +112,22 @@ export class ItemMoreOptionsComponent { get canViewPassword() { return this.cipher.viewPassword; } + + get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); + } + /** * Determines if the cipher can be autofilled. */ get canAutofill() { return ([CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]).includes( - this.cipher.type, + CipherViewLikeUtils.getType(this.cipher), ); } get isLogin() { - return this.cipher.type === CipherType.Login; + return CipherViewLikeUtils.getType(this.cipher) === CipherType.Login; } get favoriteText() { @@ -127,11 +135,13 @@ export class ItemMoreOptionsComponent { } async doAutofill() { - await this.vaultPopupAutofillService.doAutofill(this.cipher); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofill(cipher); } async doAutofillAndSave() { - await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher, false); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); } async onView() { @@ -140,7 +150,7 @@ export class ItemMoreOptionsComponent { return; } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: this.cipher.id, type: this.cipher.type }, + queryParams: { cipherId: this.cipher.id, type: CipherViewLikeUtils.getType(this.cipher) }, }); } @@ -148,11 +158,14 @@ export class ItemMoreOptionsComponent { * Toggles the favorite status of the cipher and updates it on the server. */ async toggleFavorite() { - this.cipher.favorite = !this.cipher.favorite; + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + cipher.favorite = !cipher.favorite; const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const encryptedCipher = await this.cipherService.encrypt(this.cipher, activeUserId); + + const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", @@ -176,7 +189,7 @@ export class ItemMoreOptionsComponent { return; } - if (this.cipher.login?.hasFido2Credentials) { + if (CipherViewLikeUtils.hasFido2Credentials(this.cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -192,7 +205,7 @@ export class ItemMoreOptionsComponent { queryParams: { clone: true.toString(), cipherId: this.cipher.id, - type: this.cipher.type.toString(), + type: CipherViewLikeUtils.getType(this.cipher).toString(), } as AddEditQueryParams, }); } 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 8dca1f9e57..b012b7bf15 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 @@ -97,7 +97,8 @@ (click)="primaryActionOnSelect(cipher)" (dblclick)="launchCipher(cipher)" [appA11yTitle]=" - cipherItemTitleKey()(cipher) | i18n: cipher.name : cipher.login.username + cipherItemTitleKey()(cipher) + | i18n: cipher.name : CipherViewLikeUtils.getLogin(cipher)?.username " class="{{ itemHeightClass }}" > @@ -114,11 +115,11 @@ [appA11yTitle]="orgIconTooltip(cipher)" > - {{ cipher.subTitle }} + {{ CipherViewLikeUtils.subtitle(cipher) }} @@ -134,7 +135,7 @@ {{ "fill" | i18n }} - + diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 21b857b551..000281c580 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -9,8 +9,11 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -20,7 +23,8 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service" templateUrl: "vault-items-v2.component.html", imports: [MenuModule, CommonModule, JslibModule, ScrollingModule], }) -export class VaultItemsV2Component extends BaseVaultItemsComponent { +export class VaultItemsV2Component extends BaseVaultItemsComponent { + protected CipherViewLikeUtils = CipherViewLikeUtils; constructor( searchService: SearchService, private readonly searchBarService: SearchBarService, @@ -37,7 +41,7 @@ export class VaultItemsV2Component extends BaseVaultItemsComponent { }); } - trackByFn(index: number, c: CipherView): string { - return c.id; + trackByFn(index: number, c: C): string { + return c.id!; } } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 6eb6b73789..62ca41a337 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -40,6 +40,10 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { BadgeModule, ButtonModule, @@ -124,9 +128,11 @@ const BroadcasterSubscriptionId = "VaultComponent"; }, ], }) -export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { +export class VaultV2Component + implements OnInit, OnDestroy, CopyClickListener +{ @ViewChild(VaultItemsV2Component, { static: true }) - vaultItemsComponent: VaultItemsV2Component | null = null; + vaultItemsComponent: VaultItemsV2Component | null = null; @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) @@ -407,14 +413,14 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { this.messagingService.send("minimizeOnCopy"); } - async viewCipher(cipher: CipherView) { - if (cipher.decryptionFailure) { + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipher.id as CipherId], + cipherIds: [c.id as CipherId], }); return; } - + const cipher = await this.cipherService.getFullCipherView(c); if (await this.shouldReprompt(cipher, "view")) { return; } @@ -472,7 +478,8 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { } } - viewCipherMenu(cipher: CipherView) { + async viewCipherMenu(c: CipherViewLike) { + const cipher = await this.cipherService.getFullCipherView(c); const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 197fc0ada0..47ad93d81e 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy { const filterFunction = createFilterFunction(filter); if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( this.userId, searchText, [filterFunction], @@ -772,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d79c1c4a8b..804b533c2d 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -28,6 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DIALOG_DATA, DialogRef, @@ -91,7 +92,7 @@ export interface VaultItemDialogParams { /** * Function to restore a cipher from the trash. */ - restore?: (c: CipherView) => Promise; + restore?: (c: CipherViewLike) => Promise; } export const VaultItemDialogResult = { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 227108ec25..20b87bfc03 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -4,7 +4,7 @@ type="checkbox" bitCheckbox appStopProp - [disabled]="disabled || cipher.decryptionFailure" + [disabled]="disabled || decryptionFailure" [checked]="checked" (change)="$event ? this.checkedToggled.next() : null" [attr.aria-label]="'vaultItemSelect' | i18n" @@ -30,7 +30,7 @@ > {{ cipher.name }} - +
- {{ cipher.subTitle }} + {{ subtitle }} - - @@ -119,9 +119,9 @@ @@ -151,19 +151,14 @@ {{ "eventLogs" | i18n }} - diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 6078324a05..cb4d8ad70b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -6,7 +6,10 @@ import { CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } 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 { convertToPermission, @@ -20,11 +23,11 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-cipher-row.component.html", standalone: false, }) -export class VaultCipherRowComponent implements OnInit { +export class VaultCipherRowComponent implements OnInit { protected RowHeightClass = RowHeightClass; @Input() disabled: boolean; - @Input() cipher: CipherView; + @Input() cipher: C; @Input() showOwner: boolean; @Input() showCollections: boolean; @Input() showGroups: boolean; @@ -46,7 +49,7 @@ export class VaultCipherRowComponent implements OnInit { */ @Input() canRestoreCipher: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); @@ -74,33 +77,63 @@ export class VaultCipherRowComponent implements OnInit { } protected get clickAction() { - if (this.cipher.decryptionFailure) { + if (this.decryptionFailure) { return "showFailedToDecrypt"; } + return "view"; } protected get showTotpCopyButton() { - return ( - (this.cipher.login?.hasTotp ?? false) && - (this.cipher.organizationUseTotp || this.showPremiumFeatures) - ); + const login = CipherViewLikeUtils.getLogin(this.cipher); + + const hasTotp = login?.totp ?? false; + + return hasTotp && (this.cipher.organizationUseTotp || this.showPremiumFeatures); } protected get showFixOldAttachments() { return this.cipher.hasOldAttachments && this.cipher.organizationId == null; } + protected get hasAttachments() { + return CipherViewLikeUtils.hasAttachments(this.cipher); + } + protected get showAttachments() { - return this.canEditCipher || this.cipher.attachments?.length > 0; + return this.canEditCipher || this.hasAttachments; + } + + protected get canLaunch() { + return CipherViewLikeUtils.canLaunch(this.cipher); + } + + protected get launchUri() { + return CipherViewLikeUtils.getLaunchUri(this.cipher); + } + + protected get subtitle() { + return CipherViewLikeUtils.subtitle(this.cipher); + } + + protected get isDeleted() { + return CipherViewLikeUtils.isDeleted(this.cipher); + } + + protected get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); } protected get showAssignToCollections() { - return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted; + return ( + this.organizations?.length && + this.canAssignCollections && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); } protected get showClone() { - return this.cloneable && !this.cipher.isDeleted; + return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher); } protected get showEventLogs() { @@ -108,7 +141,18 @@ export class VaultCipherRowComponent implements OnInit { } protected get isNotDeletedLoginCipher() { - return this.cipher.type === this.CipherType.Login && !this.cipher.isDeleted; + return ( + CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); + } + + protected get hasPasswordToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); + } + + protected get hasUsernameToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); } protected get permissionText() { @@ -154,7 +198,7 @@ export class VaultCipherRowComponent implements OnInit { } protected get showLaunchUri(): boolean { - return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch; + return this.isNotDeletedLoginCipher && this.canLaunch; } protected get disableMenu() { @@ -166,7 +210,7 @@ export class VaultCipherRowComponent implements OnInit { this.showAttachments || this.showClone || this.canEditCipher || - (this.cipher.isDeleted && this.canRestoreCipher) + (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher) ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index 06c78ea035..5d2b84aa10 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -5,6 +5,7 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { CollectionAdminView, Unassigned, CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -20,7 +21,7 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-collection-row.component.html", standalone: false, }) -export class VaultCollectionRowComponent { +export class VaultCollectionRowComponent { protected RowHeightClass = RowHeightClass; protected Unassigned = "unassigned"; @@ -36,7 +37,7 @@ export class VaultCollectionRowComponent { @Input() groups: GroupView[]; @Input() showPermissionsColumn: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index 272d1585d9..130f86697c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -1,17 +1,17 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { VaultItem } from "./vault-item"; -export type VaultItemEvent = - | { type: "viewAttachments"; item: CipherView } +export type VaultItemEvent = + | { type: "viewAttachments"; item: C } | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean } - | { type: "viewEvents"; item: CipherView } + | { type: "viewEvents"; item: C } | { type: "editCollection"; item: CollectionView; readonly: boolean } - | { type: "clone"; item: CipherView } - | { type: "restore"; items: CipherView[] } - | { type: "delete"; items: VaultItem[] } - | { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } - | { type: "moveToFolder"; items: CipherView[] } - | { type: "assignToCollections"; items: CipherView[] }; + | { type: "clone"; item: C } + | { type: "restore"; items: C[] } + | { type: "delete"; items: VaultItem[] } + | { type: "copyField"; item: C; field: "username" | "password" | "totp" } + | { type: "moveToFolder"; items: C[] } + | { type: "assignToCollections"; items: C[] }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/apps/web/src/app/vault/components/vault-items/vault-item.ts index 6ac198392a..bccb84fb0b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item.ts @@ -1,7 +1,7 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -export interface VaultItem { +export interface VaultItem { collection?: CollectionView; - cipher?: CipherView; + cipher?: C; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 18dfa73ac5..e82b03a881 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -6,8 +6,11 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -32,7 +35,7 @@ type ItemPermission = CollectionPermission | "NoAccess"; templateUrl: "vault-items.component.html", standalone: false, }) -export class VaultItemsComponent { +export class VaultItemsComponent { protected RowHeight = RowHeight; @Input() disabled: boolean; @@ -56,11 +59,11 @@ export class VaultItemsComponent { @Input() addAccessToggle: boolean; @Input() activeCollection: CollectionView | undefined; - private _ciphers?: CipherView[] = []; - @Input() get ciphers(): CipherView[] { + private _ciphers?: C[] = []; + @Input() get ciphers(): C[] { return this._ciphers; } - set ciphers(value: CipherView[] | undefined) { + set ciphers(value: C[] | undefined) { this._ciphers = value ?? []; this.refreshItems(); } @@ -74,11 +77,11 @@ export class VaultItemsComponent { this.refreshItems(); } - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); - protected editableItems: VaultItem[] = []; - protected dataSource = new TableDataSource(); - protected selection = new SelectionModel(true, [], true); + protected editableItems: VaultItem[] = []; + protected dataSource = new TableDataSource>(); + protected selection = new SelectionModel>(true, [], true); protected canDeleteSelected$: Observable; protected canRestoreSelected$: Observable; protected disableMenu$: Observable; @@ -233,7 +236,7 @@ export class VaultItemsComponent { : this.selection.select(...this.editableItems.slice(0, MaxSelectionCount)); } - protected event(event: VaultItemEvent) { + protected event(event: VaultItemEvent) { this.onEvent.emit(event); } @@ -263,7 +266,7 @@ export class VaultItemsComponent { } // TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead - protected canClone(vaultItem: VaultItem) { + protected canClone(vaultItem: VaultItem) { if (vaultItem.cipher.organizationId == null) { return true; } @@ -287,7 +290,7 @@ export class VaultItemsComponent { return false; } - protected canEditCipher(cipher: CipherView) { + protected canEditCipher(cipher: C) { if (cipher.organizationId == null) { return true; } @@ -296,17 +299,17 @@ export class VaultItemsComponent { return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; } - protected canAssignCollections(cipher: CipherView) { + protected canAssignCollections(cipher: C) { const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); const editableCollections = this.allCollections.filter((c) => !c.readOnly); return ( (organization?.canEditAllCiphers && this.viewingOrgVault) || - (cipher.canAssignToCollections && editableCollections.length > 0) + (CipherViewLikeUtils.canAssignToCollections(cipher) && editableCollections.length > 0) ); } - protected canManageCollection(cipher: CipherView) { + protected canManageCollection(cipher: C) { // If the cipher is not part of an organization (personal item), user can manage it if (cipher.organizationId == null) { return true; @@ -338,9 +341,11 @@ export class VaultItemsComponent { } private refreshItems() { - const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); - const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); - const items: VaultItem[] = [].concat(collections).concat(ciphers); + const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); + const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ + cipher, + })); + const items: VaultItem[] = [].concat(collections).concat(ciphers); // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( @@ -419,7 +424,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. */ - protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { // Collections before ciphers const collectionCompare = this.prioritizeCollections(a, b, direction); if (collectionCompare !== 0) { @@ -432,7 +437,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems based on group names */ - protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { if ( !(a.collection instanceof CollectionAdminView) && !(b.collection instanceof CollectionAdminView) @@ -473,8 +478,8 @@ export class VaultItemsComponent { * Sorts VaultItems based on their permissions, with higher permissions taking precedence. * If permissions are equal, it falls back to sorting by name. */ - protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { - const getPermissionPriority = (item: VaultItem): number => { + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { const permission = item.collection ? this.getCollectionPermission(item.collection) : this.getCipherPermission(item.cipher); @@ -508,8 +513,8 @@ export class VaultItemsComponent { return this.compareNames(a, b); }; - private compareNames(a: VaultItem, b: VaultItem): number { - const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; return getName(a)?.localeCompare(getName(b)) ?? -1; } @@ -517,7 +522,11 @@ export class VaultItemsComponent { * Sorts VaultItems by prioritizing collections over ciphers. * Collections are always placed before ciphers, regardless of the sorting direction. */ - private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + private prioritizeCollections( + a: VaultItem, + b: VaultItem, + direction: SortDirection, + ): number { if (a.collection && !b.collection) { return direction === "asc" ? -1 : 1; } @@ -561,7 +570,7 @@ export class VaultItemsComponent { return "NoAccess"; } - private getCipherPermission(cipher: CipherView): ItemPermission { + private getCipherPermission(cipher: C): ItemPermission { if (!cipher.organizationId || cipher.collectionIds.length === 0) { return CollectionPermission.Manage; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index e65d423a57..785c07fb63 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -36,6 +36,7 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LayoutComponent } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -158,7 +159,7 @@ export default { argTypes: { onEvent: { action: "onEvent" } }, } as Meta; -type Story = StoryObj; +type Story = StoryObj>; export const Individual: Story = { args: { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 2154ecff1b..93189f2bf1 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -85,7 +85,7 @@ describe("vault filter service", () => { policyService.policyAppliesToUser$ .calledWith(PolicyType.SingleOrg, mockUserId) .mockReturnValue(singleOrgPolicy); - cipherService.cipherViews$.mockReturnValue(cipherViews); + cipherService.cipherListViews$.mockReturnValue(cipherViews); vaultFilterService = new VaultFilterService( organizationService, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index f326034e80..1fe618c6c4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -38,6 +38,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { CipherTypeFilter, @@ -85,7 +86,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { switchMap((userId) => combineLatest([ this.folderService.folderViews$(userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this._organizationFilter, ]), ), @@ -280,7 +281,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected async filterFolders( storedFolders: FolderView[], - ciphers: CipherView[], + ciphers: CipherView[] | CipherListView[], org?: Organization, ): Promise { // If no org or "My Vault" is selected, show all folders diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 3082d7cb80..00c540f602 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -221,7 +221,7 @@ function createCipher(options: Partial = {}) { cipher.favorite = options.favorite ?? false; cipher.deletedDate = options.deletedDate; - cipher.type = options.type; + cipher.type = options.type ?? CipherType.Login; cipher.folderId = options.folderId; cipher.collectionIds = options.collectionIds; cipher.organizationId = options.organizationId; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index a39918df4a..1ed2e481fb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,40 +1,46 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } 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 { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; -export type FilterFunction = (cipher: CipherView) => boolean; +export type FilterFunction = (cipher: CipherViewLike) => boolean; export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { return (cipher) => { + const type = CipherViewLikeUtils.getType(cipher); + const isDeleted = CipherViewLikeUtils.isDeleted(cipher); + if (filter.type === "favorites" && !cipher.favorite) { return false; } - if (filter.type === "card" && cipher.type !== CipherType.Card) { + if (filter.type === "card" && type !== CipherType.Card) { return false; } - if (filter.type === "identity" && cipher.type !== CipherType.Identity) { + if (filter.type === "identity" && type !== CipherType.Identity) { return false; } - if (filter.type === "login" && cipher.type !== CipherType.Login) { + if (filter.type === "login" && type !== CipherType.Login) { return false; } - if (filter.type === "note" && cipher.type !== CipherType.SecureNote) { + if (filter.type === "note" && type !== CipherType.SecureNote) { return false; } - if (filter.type === "sshKey" && cipher.type !== CipherType.SshKey) { + if (filter.type === "sshKey" && type !== CipherType.SshKey) { return false; } - if (filter.type === "trash" && !cipher.isDeleted) { + if (filter.type === "trash" && !isDeleted) { return false; } // Hide trash unless explicitly selected - if (filter.type !== "trash" && cipher.isDeleted) { + if (filter.type !== "trash" && isDeleted) { return false; } // No folder - if (filter.folderId === Unassigned && cipher.folderId !== null) { + if (filter.folderId === Unassigned && cipher.folderId != null) { return false; } // Folder diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index b4eda51435..8dc442abe2 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -24,7 +24,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LinkModule } from "@bitwarden/components"; import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module"; @@ -44,7 +44,7 @@ import { VaultOnboardingService, VaultOnboardingTasks } from "./services/vault-o templateUrl: "vault-onboarding.component.html", }) export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { - @Input() ciphers: CipherView[]; + @Input() ciphers: CipherViewLike[]; @Input() orgs: Organization[]; @Output() onAddCipher = new EventEmitter(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 380e0280b5..c8c2f681bb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -67,8 +67,13 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult, @@ -149,7 +154,7 @@ const SearchTextDebounceInterval = 200; DefaultCipherFormConfigService, ], }) -export class VaultComponent implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; trashCleanupWarning: string = null; @@ -165,7 +170,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected canAccessPremium: boolean; protected allCollections: CollectionView[]; protected allOrganizations: Organization[] = []; - protected ciphers: CipherView[]; + protected ciphers: C[]; protected collections: CollectionView[]; protected isEmpty: boolean; protected selectedCollection: TreeNode | undefined; @@ -350,11 +355,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + const _ciphers = this.cipherService + .cipherListViews$(activeUserId) + .pipe(filter((c) => c !== null)); + /** * This observable filters the ciphers based on the active user ID and the restricted item types. */ const allowedCiphers$ = combineLatest([ - this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)), + _ciphers, this.restrictedItemTypesService.restricted$, ]).pipe( map(([ciphers, restrictedTypes]) => @@ -374,15 +383,15 @@ export class VaultComponent implements OnInit, OnDestroy { const allCiphers = [...failedCiphers, ...ciphers]; if (await this.searchService.isSearchable(activeUserId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( activeUserId, searchText, [filterFunction], - allCiphers, + allCiphers as C[], ); } - return allCiphers.filter(filterFunction); + return ciphers.filter(filterFunction) as C[]; }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -566,7 +575,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.vaultFilterService.clearOrganizationFilter(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { switch (event.type) { @@ -654,7 +663,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipher * @returns */ - async editCipherAttachments(cipher: CipherView) { + async editCipherAttachments(cipher: C) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { await this.go({ cipherId: null, itemId: null }); return; @@ -761,7 +770,7 @@ export class VaultComponent implements OnInit, OnDestroy { await this.openVaultItemDialog("form", cipherFormConfig); } - async editCipher(cipher: CipherView, cloneMode?: boolean) { + async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) { return this.editCipherId(cipher?.id, cloneMode); } @@ -929,7 +938,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkAssignToCollections(ciphers: CipherView[]) { + async bulkAssignToCollections(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -955,9 +964,28 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + let ciphersToAssign: CipherView[]; + + // Convert `CipherListView` to `CipherView` if necessary + if (ciphers.some(CipherViewLikeUtils.isCipherListView)) { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + ciphersToAssign = await firstValueFrom( + this.cipherService + .cipherViews$(userId) + .pipe( + map( + (cipherViews) => + cipherViews.filter((c) => ciphers.some((cc) => cc.id === c.id)) as CipherView[], + ), + ), + ); + } else { + ciphersToAssign = ciphers as CipherView[]; + } + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { data: { - ciphers, + ciphers: ciphersToAssign, organizationId: orgId as OrganizationId, availableCollections, activeCollection: this.activeFilter?.selectedCollectionNode?.node, @@ -970,8 +998,8 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { + async cloneCipher(cipher: CipherView | CipherListView) { + if (CipherViewLikeUtils.hasFido2Credentials(cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -986,8 +1014,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCipher(cipher, true); } - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { + restore = async (c: C): Promise => { + if (!CipherViewLikeUtils.isDeleted(c)) { return; } @@ -1014,7 +1042,7 @@ export class VaultComponent implements OnInit, OnDestroy { } }; - async bulkRestore(ciphers: CipherView[]) { + async bulkRestore(ciphers: C[]) { if (ciphers.some((c) => !c.edit)) { this.showMissingPermissionsError(); return; @@ -1044,8 +1072,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.refresh(); } - private async handleDeleteEvent(items: VaultItem[]) { - const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher); + private async handleDeleteEvent(items: VaultItem[]) { + const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher); const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection); if (ciphers.length === 1 && collections.length === 0) { await this.deleteCipher(ciphers[0]); @@ -1062,7 +1090,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async deleteCipher(c: CipherView): Promise { + async deleteCipher(c: C): Promise { if (!(await this.repromptCipher([c]))) { return; } @@ -1072,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const permanent = c.isDeleted; + const permanent = CipherViewLikeUtils.isDeleted(c); const confirmed = await this.dialogService.openSimpleDialog({ title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, @@ -1099,11 +1127,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkDelete( - ciphers: CipherView[], - collections: CollectionView[], - organizations: Organization[], - ) { + async bulkDelete(ciphers: C[], collections: CollectionView[], organizations: Organization[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1142,7 +1166,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkMove(ciphers: CipherView[]) { + async bulkMove(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1167,22 +1191,32 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async copy(cipher: CipherView, field: "username" | "password" | "totp") { + async copy(cipher: C, field: "username" | "password" | "totp") { let aType; let value; let typeI18nKey; + const login = CipherViewLikeUtils.getLogin(cipher); + + if (!login) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + } + if (field === "username") { aType = "Username"; - value = cipher.login.username; + value = login.username; typeI18nKey = "username"; } else if (field === "password") { aType = "Password"; - value = cipher.login.password; + value = await this.getPasswordFromCipherViewLike(cipher); typeI18nKey = "password"; } else if (field === "totp") { aType = "TOTP"; - const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp)); value = totpResponse.code; typeI18nKey = "verificationCodeTotp"; } else { @@ -1228,7 +1262,7 @@ export class VaultComponent implements OnInit, OnDestroy { : this.cipherService.softDeleteWithServer(id, userId); } - protected async repromptCipher(ciphers: CipherView[]) { + protected async repromptCipher(ciphers: C[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); @@ -1264,6 +1298,21 @@ export class VaultComponent implements OnInit, OnDestroy { message: this.i18nService.t("missingPermissions"), }); } + + /** + * Returns the password for a `CipherViewLike` object. + * `CipherListView` does not contain the password, the full `CipherView` needs to be fetched. + */ + private async getPasswordFromCipherViewLike(cipher: C): Promise { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + return Promise.resolve(cipher.login?.password); + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const _cipher = await this.cipherService.get(cipher.id, activeUserId); + const cipherView = await this.cipherService.decrypt(_cipher, activeUserId); + return cipherView.login?.password; + } } /** diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 475fb00403..4c4a97e640 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -864,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index fd178db23b..0718b6fc76 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -13,7 +13,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Component({ selector: "app-vault-icon", @@ -25,7 +25,7 @@ export class IconComponent { /** * The cipher to display the icon for. */ - cipher = input.required(); + cipher = input.required(); imageLoaded = signal(false); diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cf01789977..75ca560820 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -21,20 +21,23 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Directive() -export class VaultItemsComponent implements OnInit, OnDestroy { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; - @Output() onCipherClicked = new EventEmitter(); - @Output() onCipherRightClicked = new EventEmitter(); + @Output() onCipherClicked = new EventEmitter(); + @Output() onCipherRightClicked = new EventEmitter(); @Output() onAddCipher = new EventEmitter(); @Output() onAddCipherOptions = new EventEmitter(); loaded = false; - ciphers: CipherView[] = []; + ciphers: C[] = []; deleted = false; organization: Organization; CipherType = CipherType; @@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected searchPending = false; /** Construct filters as an observable so it can be appended to the cipher stream. */ - private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null); + private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null); private destroy$ = new Subject(); private isSearchable: boolean = false; private _searchText$ = new BehaviorSubject(""); @@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return this._filter$.value; } - set filter(value: (cipher: CipherView) => boolean | null) { + set filter(value: (cipher: C) => boolean | null) { this._filter$.next(value); } @@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async load(filter: (cipher: C) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); this.loaded = true; } - async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async reload(filter: (cipher: C) => boolean = null, deleted = false) { this.loaded = false; await this.load(filter, deleted); } @@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy { await this.reload(this.filter, this.deleted); } - async applyFilter(filter: (cipher: CipherView) => boolean = null) { + async applyFilter(filter: (cipher: C) => boolean = null) { this.filter = filter; } - selectCipher(cipher: CipherView) { + selectCipher(cipher: C) { this.onCipherClicked.emit(cipher); } - rightClickCipher(cipher: CipherView) { + rightClickCipher(cipher: C) { this.onCipherRightClicked.emit(cipher); } @@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return !this.searchPending && this.isSearchable; } - protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; + protected deletedFilter: (cipher: C) => boolean = (c) => + CipherViewLikeUtils.isDeleted(c) === this.deleted; /** * Creates stream of dependencies that results in the list of ciphers to display @@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { .pipe( switchMap((userId) => combineLatest([ - this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), + this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)), this.cipherService.failedToDecryptCiphers$(userId), this._searchText$, this._filter$, @@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy { ]), ), switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => { - let allCiphers = indexedCiphers ?? []; + let allCiphers = (indexedCiphers ?? []) as C[]; const _failedCiphers = failedCiphers ?? []; - allCiphers = [..._failedCiphers, ...allCiphers]; + allCiphers = [..._failedCiphers, ...allCiphers] as C[]; - const restrictedTypeFilter = (cipher: CipherView) => + const restrictedTypeFilter = (cipher: CipherViewLike) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted); return this.searchService.searchCiphers( diff --git a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts index 3122bdac2e..8302ff541a 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService { nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return combineLatest([ this.getNudgeStatus$(nudgeType, userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this.organizationService.organizations$(userId), this.collectionService.decryptedCollections$, ]).pipe( diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 8f63c31d87..fa383dd28d 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CipherType } 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 { CipherStatus } from "./cipher-status.model"; -export type VaultFilterFunction = (cipher: CipherView) => boolean; +export type VaultFilterFunction = (cipher: CipherViewLike) => boolean; export class VaultFilter { cipherType?: CipherType; @@ -44,10 +47,10 @@ export class VaultFilter { cipherPassesFilter = cipher.favorite; } if (this.status === "trash" && cipherPassesFilter) { - cipherPassesFilter = cipher.isDeleted; + cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher); } if (this.cipherType != null && cipherPassesFilter) { - cipherPassesFilter = cipher.type === this.cipherType; + cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) { cipherPassesFilter = cipher.folderId == null; @@ -68,7 +71,7 @@ export class VaultFilter { cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId; } if (this.myVaultOnly && cipherPassesFilter) { - cipherPassesFilter = cipher.organizationId === null; + cipherPassesFilter = cipher.organizationId == null; } return cipherPassesFilter; }; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index da14f7dada..1af2ab1f0a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -53,6 +53,7 @@ export enum FeatureFlag { PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", + PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", CipherKeyEncryption = "cipher-key-encryption", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", @@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, + [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, /* Auth */ diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 9f5c173826..d1d686a66a 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -20,6 +21,7 @@ import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export type EncryptionContext = { cipher: Cipher; @@ -29,6 +31,7 @@ export type EncryptionContext = { export abstract class CipherService implements UserKeyRotationDataProvider { abstract cipherViews$(userId: UserId): Observable; + abstract cipherListViews$(userId: UserId): Observable; abstract ciphers$(userId: UserId): Observable>; abstract localData$(userId: UserId): Observable>; /** @@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract filterCiphersForUrl( - ciphers: CipherView[], + abstract filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, - ): Promise; + ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** * Gets ciphers belonging to the specified organization that the user has explicit collection level access to. @@ -198,9 +201,9 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number; - abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number; - abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number; + abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number; + abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number; + abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number; abstract softDelete(id: string | string[], userId: UserId): Promise; abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; @@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + + /** + * Decrypts the full `CipherView` for a given `CipherViewLike`. + * When a `CipherView` instance is passed, it returns it as is. + */ + abstract getFullCipherView(c: CipherViewLike): Promise; } diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index c981aa748a..ed8bb2c3ba 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; import { SendView } from "../../tools/send/models/view/send.view"; import { IndexedEntityId, UserId } from "../../types/guid"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export abstract class SearchService { indexedEntityId$: (userId: UserId) => Observable; @@ -16,12 +17,16 @@ export abstract class SearchService { ciphersToIndex: CipherView[], indexedEntityGuid?: string, ) => Promise; - searchCiphers: ( + searchCiphers: ( userId: UserId, query: string, - filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], - ciphers?: CipherView[], - ) => Promise; - searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; + filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[], + ciphers?: C[], + ) => Promise; + searchCiphersBasic: ( + ciphers: C[], + query: string, + deleted?: boolean, + ) => C[]; searchSends: (sends: SendView[], query: string) => SendView[]; } diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index b7456e1ae9..a081511d79 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -1,6 +1,6 @@ import { Utils } from "../../platform/misc/utils"; import { CipherType } from "../enums/cipher-type"; -import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export interface CipherIconDetails { imageEnabled: boolean; @@ -14,7 +14,7 @@ export interface CipherIconDetails { export function buildCipherIcon( iconsServerUrl: string | null, - cipher: CipherView, + cipher: CipherViewLike, showFavicon: boolean, ): CipherIconDetails { let icon: string = "bwi-globe"; @@ -36,12 +36,16 @@ export function buildCipherIcon( showFavicon = false; } - switch (cipher.type) { + const cipherType = CipherViewLikeUtils.getType(cipher); + const uri = CipherViewLikeUtils.uri(cipher); + const card = CipherViewLikeUtils.getCard(cipher); + + switch (cipherType) { case CipherType.Login: icon = "bwi-globe"; - if (cipher.login.uri) { - let hostnameUri = cipher.login.uri; + if (uri) { + let hostnameUri = uri; let isWebsite = false; if (hostnameUri.indexOf("androidapp://") === 0) { @@ -84,8 +88,8 @@ export function buildCipherIcon( break; case CipherType.Card: icon = "bwi-credit-card"; - if (showFavicon && cipher.card.brand in cardIcons) { - icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`; + if (showFavicon && card?.brand && card.brand in cardIcons) { + icon = `credit-card-icon ${cardIcons[card.brand]}`; } break; case CipherType.Identity: diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts index ab3676930b..2933e94c30 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { CollectionId } from "@bitwarden/common/types/guid"; import { getUserId } from "../../auth/services/account.service"; -import { Cipher } from "../models/domain/cipher"; -import { CipherView } from "../models/view/cipher.view"; - -/** - * Represents either a cipher or a cipher view. - */ -type CipherLike = Cipher | CipherView; +import { CipherLike } from "../types/cipher-like"; /** * Service for managing user cipher authorization. @@ -95,7 +89,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.delete; + return !!cipher.permissions?.delete; }), ); } @@ -118,7 +112,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.restore; + return !!cipher.permissions?.restore; }), ); } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index c967f2614c..8bef5289a9 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; import { ADD_EDIT_CIPHER_INFO_KEY, @@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction { return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {})); } + /** + * Observable that emits an array of decrypted ciphers for given userId. + * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. + * + * This uses the SDK for decryption, when the `PM22134SdkCipherListView` feature flag is disabled the full `cipherViews$` observable will be emitted. + * Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported. + */ + cipherListViews$ = perUserCache$((userId: UserId) => { + return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe( + switchMap((useSdk) => { + if (!useSdk) { + return this.cipherViews$(userId); + } + + return combineLatest([ + this.encryptedCiphersState(userId).state$, + this.localData$(userId), + this.keyService.cipherDecryptionKeys$(userId, true), + ]).pipe( + filter(([cipherDataState, _, keys]) => cipherDataState != null && keys != null), + map(([cipherDataState, localData]) => + Object.values(cipherDataState).map( + (cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]), + ), + ), + switchMap(async (ciphers) => { + // TODO: remove this once failed decrypted ciphers are handled in the SDK + await this.setFailedDecryptedCiphers([], userId); + return this.cipherEncryptionService + .decryptMany(ciphers, userId) + .then((ciphers) => ciphers.sort(this.getLocaleSortingFunction())); + }), + ); + }), + ); + }); + /** * Observable that emits an array of decrypted ciphers for the active user. * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. @@ -543,18 +581,23 @@ export class CipherService implements CipherServiceAbstraction { filter((c) => c != null), switchMap( async (ciphers) => - await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch), + await this.filterCiphersForUrl( + ciphers, + url, + includeOtherTypes, + defaultMatch, + ), ), ), ); } - async filterCiphersForUrl( - ciphers: CipherView[], + async filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, - ): Promise { + ): Promise { if (url == null && includeOtherTypes == null) { return []; } @@ -565,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction { defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); return ciphers.filter((cipher) => { - const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; + const type = CipherViewLikeUtils.getType(cipher); + const login = CipherViewLikeUtils.getLogin(cipher); + const cipherIsLogin = login !== null; - if (cipher.deletedDate !== null) { + if (CipherViewLikeUtils.isDeleted(cipher)) { return false; } - if ( - Array.isArray(includeOtherTypes) && - includeOtherTypes.includes(cipher.type) && - !cipherIsLogin - ) { + if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) { return true; } if (cipherIsLogin) { - return cipher.login.matchesUri(url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); } return false; @@ -1173,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction { return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId); } - sortCiphersByLastUsed(a: CipherView, b: CipherView): number { + sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number { const aLastUsed = a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null; const bLastUsed = @@ -1197,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction { return 0; } - sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number { + sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number { const result = this.sortCiphersByLastUsed(a, b); if (result !== 0) { return result; @@ -1206,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction { return this.getLocaleSortingFunction()(a, b); } - getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number { + getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number { return (a, b) => { let aName = a.name; let bName = b.name; @@ -1225,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction { ? this.i18nService.collator.compare(aName, bName) : aName.localeCompare(bName); - if (result !== 0 || a.type !== CipherType.Login || b.type !== CipherType.Login) { + const aType = CipherViewLikeUtils.getType(a); + const bType = CipherViewLikeUtils.getType(b); + + if (result !== 0 || aType !== CipherType.Login || bType !== CipherType.Login) { return result; } - if (a.login.username != null) { - aName += a.login.username; + const aLogin = CipherViewLikeUtils.getLogin(a); + const bLogin = CipherViewLikeUtils.getLogin(b); + + if (aLogin.username != null) { + aName += aLogin.username; } - if (b.login.username != null) { - bName += b.login.username; + if (bLogin.username != null) { + bName += bLogin.username; } return this.i18nService.collator @@ -1902,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction { return decryptedViews.sort(this.getLocaleSortingFunction()); } + + /** Fetches the full `CipherView` when a `CipherListView` is passed. */ + async getFullCipherView(c: CipherViewLike): Promise { + if (CipherViewLikeUtils.isCipherListView(c)) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const cipher = await this.get(c.id!, activeUserId); + return this.decrypt(cipher, activeUserId); + } + + return Promise.resolve(c); + } } diff --git a/libs/common/src/vault/services/restricted-item-types.service.ts b/libs/common/src/vault/services/restricted-item-types.service.ts index 6b848e6626..8ccc94d365 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.ts @@ -9,17 +9,15 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { Cipher } from "../models/domain/cipher"; +import { CipherLike } from "../types/cipher-like"; +import { CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type RestrictedCipherType = { cipherType: CipherType; allowViewOrgIds: string[]; }; -type CipherLike = Cipher | CipherView; - export class RestrictedItemTypesService { /** * Emits an array of RestrictedCipherType objects: @@ -94,7 +92,9 @@ export class RestrictedItemTypesService { * - Otherwise → restricted */ isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean { - const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type); + const restriction = restrictedTypes.find( + (r) => r.cipherType === CipherViewLikeUtils.getType(cipher), + ); // If cipher type is not restricted by any organization, allow it if (!restriction) { diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index 8e54fa695b..614fba4a7c 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type SerializedLunrIndex = { version: string; @@ -197,13 +198,13 @@ export class SearchService implements SearchServiceAbstraction { ]); } - async searchCiphers( + async searchCiphers( userId: UserId, query: string, - filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null, - ciphers: CipherView[], - ): Promise { - const results: CipherView[] = []; + filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null, + ciphers: C[], + ): Promise { + const results: C[] = []; if (query != null) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); } @@ -218,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction { if (filter != null && Array.isArray(filter) && filter.length > 0) { ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c))); } else if (filter != null) { - ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); + ciphers = ciphers.filter(filter as (cipher: C) => boolean); } if (!(await this.isSearchable(userId, query))) { @@ -238,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction { return this.searchCiphersBasic(ciphers, query); } - const ciphersMap = new Map(); + const ciphersMap = new Map(); ciphers.forEach((c) => ciphersMap.set(c.id, c)); let searchResults: lunr.Index.Result[] = null; @@ -272,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction { return results; } - searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) { + searchCiphersBasic(ciphers: C[], query: string, deleted = false) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); return ciphers.filter((c) => { - if (deleted !== c.isDeleted) { + if (deleted !== CipherViewLikeUtils.isDeleted(c)) { return false; } if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { @@ -284,13 +285,17 @@ export class SearchService implements SearchServiceAbstraction { if (query.length >= 8 && c.id.startsWith(query)) { return true; } - if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) { + const subtitle = CipherViewLikeUtils.subtitle(c); + if (subtitle != null && subtitle.toLowerCase().indexOf(query) > -1) { return true; } + + const login = CipherViewLikeUtils.getLogin(c); + if ( - c.login && - c.login.hasUris && - c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) + login && + login.uris.length && + login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) ) { return true; } diff --git a/libs/common/src/vault/types/cipher-like.ts b/libs/common/src/vault/types/cipher-like.ts new file mode 100644 index 0000000000..61fb4ef86a --- /dev/null +++ b/libs/common/src/vault/types/cipher-like.ts @@ -0,0 +1,9 @@ +import { Cipher } from "../models/domain/cipher"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; + +/** + * Represents either a Cipher, CipherView or CipherListView. + * + * {@link CipherViewLikeUtils} provides logic to perform operations on each type. + */ +export type CipherLike = Cipher | CipherViewLike; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts new file mode 100644 index 0000000000..f302340ef9 --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -0,0 +1,624 @@ +import { CipherListView } from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Attachment } from "../models/domain/attachment"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { Fido2CredentialView } from "../models/view/fido2-credential.view"; +import { IdentityView } from "../models/view/identity.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +import { CipherViewLikeUtils } from "./cipher-view-like-utils"; + +describe("CipherViewLikeUtils", () => { + const createCipherView = (type: CipherType = CipherType.Login): CipherView => { + const cipherView = new CipherView(); + // Always set a type to avoid issues within `CipherViewLikeUtils` + cipherView.type = type; + + return cipherView; + }; + + describe("isCipherListView", () => { + it("returns true when the cipher is a CipherListView", () => { + const cipherListViewLogin = { + type: { + login: {}, + }, + } as CipherListView; + const cipherListViewSshKey = { + type: "sshKey", + } as CipherListView; + + expect(CipherViewLikeUtils.isCipherListView(cipherListViewLogin)).toBe(true); + expect(CipherViewLikeUtils.isCipherListView(cipherListViewSshKey)).toBe(true); + }); + + it("returns false when the cipher is not a CipherListView", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.SecureNote; + + expect(CipherViewLikeUtils.isCipherListView(cipherView)).toBe(false); + }); + }); + + describe("getLogin", () => { + it("returns null when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getLogin({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the login object", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toEqual(cipherView.login); + }); + }); + + describe("CipherListView", () => { + it("returns the login object", () => { + const cipherListView = { + type: { + login: { + username: "testuser", + hasFido2: false, + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLogin(cipherListView)).toEqual( + (cipherListView.type as any).login, + ); + }); + }); + }); + + describe("getCard", () => { + it("returns null when the cipher is not a card", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getCard(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getCard({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the card object", () => { + const cipherView = createCipherView(CipherType.Card); + + expect(CipherViewLikeUtils.getCard(cipherView)).toEqual(cipherView.card); + }); + }); + + describe("CipherListView", () => { + it("returns the card object", () => { + const cipherListView = { + type: { + card: { + brand: "Visa", + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getCard(cipherListView)).toEqual( + (cipherListView.type as any).card, + ); + }); + }); + }); + + describe("isDeleted", () => { + it("returns true when the cipher is deleted", () => { + const cipherListView = { deletedDate: "2024-02-02", type: "identity" } as CipherListView; + const cipherView = createCipherView(); + cipherView.deletedDate = new Date(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(true); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(true); + }); + + it("returns false when the cipher is not deleted", () => { + const cipherListView = { deletedDate: undefined, type: "identity" } as CipherListView; + const cipherView = createCipherView(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(false); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(false); + }); + }); + + describe("canAssignToCollections", () => { + describe("CipherView", () => { + let cipherView: CipherView; + + beforeEach(() => { + cipherView = createCipherView(); + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = false; + cipherView.viewPassword = false; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = true; + cipherView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + }); + + describe("CipherListView", () => { + let cipherListView: CipherListView; + + beforeEach(() => { + cipherListView = { + organizationId: undefined, + edit: false, + viewPassword: false, + type: { login: {} }, + } as CipherListView; + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherListView.organizationId = "org-id"; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherListView.organizationId = "org-id"; + cipherListView.edit = true; + cipherListView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + }); + }); + + describe("getType", () => { + describe("CipherView", () => { + it("returns the type of the cipher", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.Login; + + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Login); + + cipherView.type = CipherType.SecureNote; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SecureNote); + + cipherView.type = CipherType.SshKey; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SshKey); + + cipherView.type = CipherType.Identity; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Identity); + + cipherView.type = CipherType.Card; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Card); + }); + }); + + describe("CipherListView", () => { + it("converts the `CipherViewListType` to `CipherType`", () => { + const cipherListView = { + type: { login: {} }, + } as CipherListView; + + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Login); + + cipherListView.type = { card: { brand: "Visa" } }; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Card); + + cipherListView.type = "sshKey"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SshKey); + + cipherListView.type = "identity"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Identity); + + cipherListView.type = "secureNote"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SecureNote); + }); + }); + }); + + describe("subtitle", () => { + describe("CipherView", () => { + it("returns the subtitle of the cipher", () => { + const cipherView = createCipherView(); + cipherView.login = new LoginView(); + cipherView.login.username = "Test Username"; + + expect(CipherViewLikeUtils.subtitle(cipherView)).toBe("Test Username"); + }); + }); + + describe("CipherListView", () => { + it("returns the subtitle of the cipher", () => { + const cipherListView = { + subtitle: "Test Subtitle", + type: "identity", + } as CipherListView; + + expect(CipherViewLikeUtils.subtitle(cipherListView)).toBe("Test Subtitle"); + }); + }); + }); + + describe("hasAttachments", () => { + describe("CipherView", () => { + it("returns true when the cipher has attachments", () => { + const cipherView = createCipherView(); + cipherView.attachments = [new AttachmentView({ id: "1" } as Attachment)]; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(true); + }); + + it("returns false when the cipher has no attachments", () => { + const cipherView = new CipherView(); + (cipherView.attachments as any) = null; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when there are attachments", () => { + const cipherListView = { attachments: 1, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(true); + }); + + it("returns false when there are no attachments", () => { + const cipherListView = { attachments: 0, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(false); + }); + }); + }); + + describe("canLaunch", () => { + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + expect(CipherViewLikeUtils.canLaunch({ type: "identity" } as CipherListView)).toBe(false); + }); + + describe("CipherView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [{ uri: "https://example.com" } as LoginUriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "bitwarden.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(false); + }); + }); + }); + + describe("getLaunchUri", () => { + it("returns undefined when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + expect( + CipherViewLikeUtils.getLaunchUri({ type: "identity" } as CipherListView), + ).toBeUndefined(); + }); + + describe("CipherView", () => { + it("returns the first launch-able URI", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [ + { uri: "" } as LoginUriView, + { uri: "https://example.com" } as LoginUriView, + { uri: "https://another.com" } as LoginUriView, + ]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + }); + + it("appends protocol when there are none", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("http://bitwarden.com"); + }); + }); + + describe("CipherListView", () => { + it("returns the first launch-able URI", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "" }, { uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBeUndefined(); + }); + }); + }); + + describe("matchesUri", () => { + const emptySet = new Set(); + + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + false, + ); + }); + + describe("CipherView", () => { + it("returns true when the URI matches", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://example.com"; + cipherView.login.uris = [uri]; + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + true, + ); + }); + + it("returns false when the URI does not match", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://www.bitwarden.com"; + cipherView.login.uris = [uri]; + + expect( + CipherViewLikeUtils.matchesUri(cipherView, "https://www.another.com", emptySet), + ).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the URI matches", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://example.com", emptySet), + ).toBe(true); + }); + + it("returns false when the URI does not match", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://bitwarden.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://another.com", emptySet), + ).toBe(false); + }); + }); + }); + + describe("hasCopyableValue", () => { + describe("CipherView", () => { + it("returns true for login fields", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(true); + }); + + it("returns true for card fields", () => { + const cipherView = createCipherView(CipherType.Card); + cipherView.card = { number: "1234-5678-9012-3456", code: "123" } as any; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(true); + }); + + it("returns true for identity fields", () => { + const cipherView = createCipherView(CipherType.Identity); + cipherView.identity = new IdentityView(); + cipherView.identity.email = "example@bitwarden.com"; + cipherView.identity.phone = "123-456-7890"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "phone")).toBe(true); + }); + + it("returns false when values are not populated", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true for copyable fields in a login cipher", () => { + const cipherListView = { + type: { login: { username: "testuser" } }, + copyableFields: ["LoginUsername", "LoginPassword"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(true); + }); + + it("returns true for copyable fields in a card cipher", () => { + const cipherListView = { + type: { card: { brand: "MasterCard" } }, + copyableFields: ["CardNumber", "CardSecurityCode"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "securityCode")).toBe(true); + }); + + it("returns true for copyable fields in an sshKey ciphers", () => { + const cipherListView = { + type: "sshKey", + copyableFields: ["SshKey"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "privateKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "keyFingerprint")).toBe(true); + }); + + it("returns true for copyable fields in an identity cipher", () => { + const cipherListView = { + type: "identity", + copyableFields: ["IdentityUsername", "IdentityEmail", "IdentityPhone"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(true); + }); + + it("returns false for when missing a field", () => { + const cipherListView = { + type: { login: {} }, + copyableFields: ["LoginUsername"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "address")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(false); + }); + }); + }); + + describe("hasFido2Credentials", () => { + describe("CipherView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.fido2Credentials = [new Fido2CredentialView()]; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherListView = { + type: { login: { fido2Credentials: [{ credentialId: "fido2-1" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(false); + }); + }); + }); + + describe("decryptionFailure", () => { + it("returns true when the cipher has a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = true; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(true); + }); + + it("returns false when the cipher does not have a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = false; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(false); + }); + + it("returns false when the cipher is a CipherListView without decryptionFailure", () => { + const cipherListView = { type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts new file mode 100644 index 0000000000..1c7a4382a0 --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -0,0 +1,301 @@ +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { + CardListView, + CipherListView, + CopyableCipherFields, + LoginListView, + LoginUriView as LoginListUriView, +} from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Cipher } from "../models/domain/cipher"; +import { CardView } from "../models/view/card.view"; +import { CipherView } from "../models/view/cipher.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +/** + * Type union of {@link CipherView} and {@link CipherListView}. + */ +export type CipherViewLike = CipherView | CipherListView; + +/** + * Utility class for working with ciphers that can be either a {@link CipherView} or a {@link CipherListView}. + */ +export class CipherViewLikeUtils { + /** @returns true when the given cipher is an instance of {@link CipherListView}. */ + static isCipherListView = (cipher: CipherViewLike | Cipher): cipher is CipherListView => { + return typeof cipher.type === "object" || typeof cipher.type === "string"; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getLogin = (cipher: CipherViewLike): LoginListView | LoginView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "login" in cipher.type ? cipher.type.login : null; + } + + return cipher.type === CipherType.Login ? cipher.login : null; + }; + + /** @returns The first URI for a login cipher. If the cipher is not of type Login or has no associated URIs, returns null. */ + static uri = (cipher: CipherViewLike) => { + const login = this.getLogin(cipher); + if (!login) { + return null; + } + + if ("uri" in login) { + return login.uri; + } + + return login.uris?.length ? login.uris[0].uri : null; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getCard = (cipher: CipherViewLike): CardListView | CardView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "card" in cipher.type ? cipher.type.card : null; + } + + return cipher.type === CipherType.Card ? cipher.card : null; + }; + + /** @returns `true` when the cipher has been deleted, `false` otherwise. */ + static isDeleted = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return !!cipher.deletedDate; + } + + return cipher.isDeleted; + }; + + /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */ + static canAssignToCollections = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + if (!cipher.organizationId) { + return true; + } + + return cipher.edit && cipher.viewPassword; + } + + return cipher.canAssignToCollections; + }; + + /** + * Returns the type of the cipher. + * For consistency, when the given cipher is a {@link CipherListView} the {@link CipherType} equivalent will be returned. + */ + static getType = (cipher: CipherViewLike | Cipher): CipherType => { + if (!this.isCipherListView(cipher)) { + return cipher.type; + } + + // CipherListViewType is a string, so we need to map it to CipherType. + switch (true) { + case cipher.type === "secureNote": + return CipherType.SecureNote; + case cipher.type === "sshKey": + return CipherType.SshKey; + case cipher.type === "identity": + return CipherType.Identity; + case typeof cipher.type === "object" && "card" in cipher.type: + return CipherType.Card; + case typeof cipher.type === "object" && "login" in cipher.type: + return CipherType.Login; + default: + throw new Error(`Unknown cipher type: ${cipher.type}`); + } + }; + + /** @returns The subtitle of the cipher. */ + static subtitle = (cipher: CipherViewLike): string | undefined => { + if (!this.isCipherListView(cipher)) { + return cipher.subTitle; + } + + return cipher.subtitle; + }; + + /** @returns `true` when the cipher has attachments, false otherwise. */ + static hasAttachments = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return typeof cipher.attachments === "number" && cipher.attachments > 0; + } + + return cipher.hasAttachments; + }; + + /** + * @returns `true` when one of the URIs for the cipher can be launched. + * When a non-login cipher is passed, it will return false. + */ + static canLaunch = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + if (!login) { + return false; + } + + return !!login.uris?.map((u) => toLoginUriView(u)).some((uri) => uri.canLaunch); + }; + + /** + * @returns The first launch-able URI for the cipher. + * When a non-login cipher is passed or none of the URLs, it will return undefined. + */ + static getLaunchUri = (cipher: CipherViewLike): string | undefined => { + const login = this.getLogin(cipher); + + if (!login) { + return undefined; + } + + return login.uris?.map((u) => toLoginUriView(u)).find((uri) => uri.canLaunch)?.launchUri; + }; + + /** + * @returns `true` when the `targetUri` matches for any URI on the cipher. + * Uses the existing logic from `LoginView.matchesUri` for both `CipherView` and `CipherListView` + */ + static matchesUri = ( + cipher: CipherViewLike, + targetUri: string, + equivalentDomains: Set, + defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + ): boolean => { + if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { + return false; + } + + if (!this.isCipherListView(cipher)) { + return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + } + + const login = this.getLogin(cipher); + if (!login?.uris?.length) { + return false; + } + + const loginUriViews = login.uris + .filter((u) => !!u.uri) + .map((u) => { + const view = new LoginUriView(); + view.match = u.match ?? defaultUriMatch; + view.uri = u.uri!; // above `filter` ensures `u.uri` is not null or undefined + return view; + }); + + return loginUriViews.some((uriView) => + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + ); + }; + + /** @returns true when the `copyField` is populated on the given cipher. */ + static hasCopyableValue = (cipher: CipherViewLike, copyField: string): boolean => { + // `CipherListView` instances do not contain the values to be copied, but rather a list of copyable fields. + // When the copy action is performed on a `CipherListView`, the full cipher will need to be decrypted. + if (this.isCipherListView(cipher)) { + let _copyField = copyField; + + if (_copyField === "username" && this.getType(cipher) === CipherType.Login) { + _copyField = "usernameLogin"; + } else if (_copyField === "username" && this.getType(cipher) === CipherType.Identity) { + _copyField = "usernameIdentity"; + } + + return cipher.copyableFields.includes(copyActionToCopyableFieldMap[_copyField]); + } + + // When the full cipher is available, check the specific field + switch (copyField) { + case "username": + return !!cipher.login?.username || !!cipher.identity?.username; + case "password": + return !!cipher.login?.password; + case "totp": + return !!cipher.login?.totp; + case "cardNumber": + return !!cipher.card?.number; + case "securityCode": + return !!cipher.card?.code; + case "email": + return !!cipher.identity?.email; + case "phone": + return !!cipher.identity?.phone; + case "address": + return !!cipher.identity?.fullAddressForCopy; + case "secureNote": + return !!cipher.notes; + case "privateKey": + return !!cipher.sshKey?.privateKey; + case "publicKey": + return !!cipher.sshKey?.publicKey; + case "keyFingerprint": + return !!cipher.sshKey?.keyFingerprint; + default: + return false; + } + }; + + /** @returns true when the cipher has fido2 credentials */ + static hasFido2Credentials = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + return !!login?.fido2Credentials?.length; + }; + + /** + * Returns the `decryptionFailure` property from the cipher when available. + * TODO: https://bitwarden.atlassian.net/browse/PM-22515 - alter for `CipherListView` if needed + */ + static decryptionFailure = (cipher: CipherViewLike): boolean => { + return "decryptionFailure" in cipher ? cipher.decryptionFailure : false; + }; +} + +/** + * Mapping between the generic copy actions and the specific fields in a `CipherViewLike`. + */ +const copyActionToCopyableFieldMap: Record = { + usernameLogin: "LoginUsername", + password: "LoginPassword", + totp: "LoginTotp", + cardNumber: "CardNumber", + securityCode: "CardSecurityCode", + usernameIdentity: "IdentityUsername", + email: "IdentityEmail", + phone: "IdentityPhone", + address: "IdentityAddress", + secureNote: "SecureNotes", + privateKey: "SshKey", + publicKey: "SshKey", + keyFingerprint: "SshKey", +}; + +/** Converts a `LoginListUriView` to a `LoginUriView`. */ +const toLoginUriView = (uri: LoginListUriView | LoginUriView): LoginUriView => { + if (uri instanceof LoginUriView) { + return uri; + } + + const loginUriView = new LoginUriView(); + if (uri.match) { + loginUriView.match = uri.match; + } + if (uri.uri) { + loginUriView.uri = uri.uri; + } + return loginUriView; +}; diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts index 0847e7147a..a3650c68c9 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.spec.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -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(); + mockAccountService.activeAccount$ = of({ id: "test-account-id" } as Account); + mockCipherService = mock(); 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([ diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 0ab7400a6d..59ad8bf38e 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -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; - @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; } diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts index 5b038376ae..3bd8f911f3 100644 --- a/libs/vault/src/services/copy-cipher-field.service.spec.ts +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -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; diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 3f94b27cef..606614f214 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -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 { 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 { + async totpAllowed(cipher: CipherViewLike): Promise { 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), diff --git a/libs/vault/src/services/password-reprompt.service.ts b/libs/vault/src/services/password-reprompt.service.ts index 6583d0787f..e6a6b20b32 100644 --- a/libs/vault/src/services/password-reprompt.service.ts +++ b/libs/vault/src/services/password-reprompt.service.ts @@ -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; }