1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

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

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

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

* migrate web vault components to support

* add  for CipherView.  will have to be later

* fetch full CipherView for copying a password

* have only the cipher service utilize SDK migration flag

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

* strongly type CipherView for AC vault

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

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

* migrate desktop components to CipherListViews$

* consume card from sdk

* add browser implementation for `CipherListView`

* update copy message for single copiable items

* refactor `getCipherViewLikeLogin` to `getLogin`

* refactor `getCipherViewLikeCard` to `getCard`

* add `hasFido2Credentials` helper

* add decryption failure to cipher like utils

* add todo with ticket

* fix decryption failure typing

* fix copy card messages

* fix addition of organizations and collections for `PopupCipherViewLike`

- accessors were being lost

* refactor to getters to fix re-rendering bug

* fix decryption failure helper

* fix sorting functions for `CipherViewLike`

* formatting

* add `CipherViewLikeUtils` tests

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

* use `hasOldAttachments` from cipherlistview

* fix typing

* update SDK version

* add feature flag for cipher list view work

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

* update restricted item type to support CipherViewLike

* add cipher support to `CipherViewLikeUtils`

* update `isCipherListView` check

* refactor CipherLike to a separate type

* refactor `getFullCipherView` into the cipher service

* add optional chaining for `uriChecksum`

* set empty array for decrypted CipherListView

* migrate nudge service to use `cipherListViews`

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

* update popup list filters to use `CipherListView`

* fix storybook

* fix tests

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

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

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

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

View File

@@ -1829,6 +1829,9 @@
"securityCode": { "securityCode": {
"message": "Security code" "message": "Security code"
}, },
"cardNumber": {
"message": "card number"
},
"ex": { "ex": {
"message": "ex." "message": "ex."
}, },
@@ -4563,17 +4566,17 @@
} }
} }
}, },
"copyFieldValue": { "copyFieldCipherName": {
"message": "Copy $FIELD$, $VALUE$", "message": "Copy $FIELD$, $CIPHERNAME$",
"description": "Title for a button that copies a field value to the clipboard.", "description": "Title for a button that copies a field value to the clipboard.",
"placeholders": { "placeholders": {
"field": { "field": {
"content": "$1", "content": "$1",
"example": "Username" "example": "Username"
}, },
"value": { "ciphername": {
"content": "$2", "content": "$2",
"example": "Foo" "example": "Login Item"
} }
} }
}, },

View File

@@ -6,12 +6,13 @@ import { combineLatest, map, Observable, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { IconButtonModule, TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.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"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
@Component({ @Component({
@@ -30,7 +31,7 @@ export class AutofillVaultListItemsComponent {
* The list of ciphers that can be used to autofill the current page. * The list of ciphers that can be used to autofill the current page.
* @protected * @protected
*/ */
protected autofillCiphers$: Observable<PopupCipherView[]> = protected autofillCiphers$: Observable<PopupCipherViewLike[]> =
this.vaultPopupItemsService.autoFillCiphers$; this.vaultPopupItemsService.autoFillCiphers$;
/** /**
@@ -62,7 +63,9 @@ export class AutofillVaultListItemsComponent {
]).pipe( ]).pipe(
map( map(
([hasFilter, ciphers, canAutoFill]) => ([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,
), ),
); );

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="cipher.type === CipherType.Login"> <ng-container *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Login">
<ng-container *ngIf="showQuickCopyActions$ | async; else loginCopyMenu"> <ng-container *ngIf="showQuickCopyActions$ | async; else loginCopyMenu">
<bit-item-action> <bit-item-action>
<button <button
@@ -36,15 +36,15 @@
<ng-template #loginCopyMenu> <ng-template #loginCopyMenu>
<bit-item-action> <bit-item-action>
<button <button
*ngIf="singleCopiableLogin" *ngIf="singleCopyableLogin"
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
size="small" size="small"
[appA11yTitle]="singleCopiableLogin.key" [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableLogin.key : cipher.name"
[appCopyField]="$any(singleCopiableLogin.field)" [appCopyField]="singleCopyableLogin.field"
[cipher]="cipher" [cipher]="cipher"
></button> ></button>
<ng-container *ngIf="!singleCopiableLogin"> <ng-container *ngIf="!singleCopyableLogin">
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
@@ -77,7 +77,7 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container *ngIf="cipher.type === CipherType.Card"> <ng-container *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Card">
<ng-container *ngIf="showQuickCopyActions$ | async; else cardCopyMenu"> <ng-container *ngIf="showQuickCopyActions$ | async; else cardCopyMenu">
<bit-item-action> <bit-item-action>
<button <button
@@ -103,16 +103,16 @@
<ng-template #cardCopyMenu> <ng-template #cardCopyMenu>
<bit-item-action> <bit-item-action>
<button <button
*ngIf="singleCopiableCard" *ngIf="singleCopyableCard"
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
size="small" size="small"
[appA11yTitle]="'copyFieldValue' | i18n: singleCopiableCard.key : singleCopiableCard.value" [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableCard.key : cipher.name"
[appCopyClick]="singleCopiableCard.value" [appCopyField]="singleCopyableCard.field"
[valueLabel]="singleCopiableCard.key" [cipher]="cipher"
showToast showToast
></button> ></button>
<ng-container *ngIf="!singleCopiableCard"> <ng-container *ngIf="!singleCopyableCard">
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
@@ -136,20 +136,18 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<bit-item-action *ngIf="cipher.type === CipherType.Identity"> <bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.Identity">
<button <button
*ngIf="singleCopiableIdentity" *ngIf="singleCopyableIdentity"
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
size="small" size="small"
[appA11yTitle]=" [appA11yTitle]="'copyFieldCipherName' | i18n: singleCopyableIdentity.key : cipher.name"
'copyFieldValue' | i18n: singleCopiableIdentity.key : singleCopiableIdentity.value [appCopyField]="singleCopyableIdentity.field"
" [cipher]="cipher"
[appCopyClick]="singleCopiableIdentity.value"
[valueLabel]="singleCopiableIdentity.key"
showToast showToast
></button> ></button>
<ng-container *ngIf="!singleCopiableIdentity"> <ng-container *ngIf="!singleCopyableIdentity">
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
@@ -177,7 +175,7 @@
</ng-container> </ng-container>
</bit-item-action> </bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SecureNote"> <bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.SecureNote">
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"
@@ -190,7 +188,7 @@
></button> ></button>
</bit-item-action> </bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SshKey"> <bit-item-action *ngIf="CipherViewLikeUtils.getType(cipher) === CipherType.SshKey">
<button <button
type="button" type="button"
bitIconButton="bwi-clone" bitIconButton="bwi-clone"

View File

@@ -1,21 +1,24 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input, inject } from "@angular/core"; import { Component, Input, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault"; import { CopyableCipherFields } from "@bitwarden/sdk-internal";
import { CopyAction, CopyCipherFieldDirective } from "@bitwarden/vault";
import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service"; import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service";
type CipherItem = { type CipherItem = {
value: string; /** Translation key for the respective value */
key: string; key: string;
field?: string; /** Property key on `CipherView` to retrieve the copy value */
field: CopyAction;
}; };
@Component({ @Component({
@@ -32,91 +35,155 @@ type CipherItem = {
}) })
export class ItemCopyActionsComponent { export class ItemCopyActionsComponent {
protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$; protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$;
@Input({ required: true }) cipher!: CipherViewLike;
@Input() cipher: CipherView; protected CipherViewLikeUtils = CipherViewLikeUtils;
protected CipherType = CipherType; protected CipherType = CipherType;
get hasLoginValues() {
return (
!!this.cipher.login.hasTotp || !!this.cipher.login.password || !!this.cipher.login.username
);
}
/* /*
* singleCopiableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP * singleCopyableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP
* code to be copied correctly. See #14167 * code to be copied correctly. See #14167
*/ */
get singleCopiableLogin() { get singleCopyableLogin() {
const loginItems: CipherItem[] = [ const loginItems: CipherItem[] = [
{ value: this.cipher.login.username, key: "copyUsername", field: "username" }, { key: "copyUsername", field: "username" },
{ value: this.cipher.login.password, key: "copyPassword", field: "password" }, { key: "copyPassword", field: "password" },
{ value: this.cipher.login.totp, key: "copyVerificationCode", field: "totp" }, { key: "copyVerificationCode", field: "totp" },
]; ];
// If both the password and username are visible but the password is hidden, return the username // If both the password and username are visible but the password is hidden, return the username
if (!this.cipher.viewPassword && this.cipher.login.username && this.cipher.login.password) { if (
!this.cipher.viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") &&
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")
) {
return { return {
value: this.cipher.login.username,
key: this.i18nService.t("copyUsername"), key: this.i18nService.t("copyUsername"),
field: "username", field: "username",
}; };
} }
return this.findSingleCopiableItem(loginItems); return this.findSingleCopyableItem(loginItems);
} }
get singleCopiableCard() { get singleCopyableCard() {
const cardItems: CipherItem[] = [ const cardItems: CipherItem[] = [
{ value: this.cipher.card.code, key: "code" }, { key: "securityCode", field: "securityCode" },
{ value: this.cipher.card.number, key: "number" }, { key: "cardNumber", field: "cardNumber" },
]; ];
return this.findSingleCopiableItem(cardItems); return this.findSingleCopyableItem(cardItems);
} }
get singleCopiableIdentity() { get singleCopyableIdentity() {
const identityItems: CipherItem[] = [ const identityItems: CipherItem[] = [
{ value: this.cipher.identity.fullAddressForCopy, key: "address" }, { key: "address", field: "address" },
{ value: this.cipher.identity.email, key: "email" }, { key: "email", field: "email" },
{ value: this.cipher.identity.username, key: "username" }, { key: "username", field: "username" },
{ value: this.cipher.identity.phone, key: "phone" }, { key: "phone", field: "phone" },
]; ];
return this.findSingleCopiableItem(identityItems); return this.findSingleCopyableItem(identityItems);
} }
/* /*
* Given a list of CipherItems, if there is only one item with a value, * Given a list of CipherItems, if there is only one item with a value,
* return it with the translated key. Otherwise return null * return it with the translated key. Otherwise return null
*/ */
findSingleCopiableItem(items: CipherItem[]): CipherItem | null { findSingleCopyableItem(items: CipherItem[]): CipherItem | null {
const itemsWithValue = items.filter(({ value }) => !!value); const itemsWithValue = items.filter(({ field }) =>
CipherViewLikeUtils.hasCopyableValue(this.cipher, field),
);
return itemsWithValue.length === 1 return itemsWithValue.length === 1
? { ...itemsWithValue[0], key: this.i18nService.t(itemsWithValue[0].key) } ? { ...itemsWithValue[0], key: this.i18nService.t(itemsWithValue[0].key) }
: null; : null;
} }
get hasLoginValues() {
return this.getNumberOfLoginValues() > 0;
}
get hasCardValues() { get hasCardValues() {
return !!this.cipher.card.code || !!this.cipher.card.number; return this.getNumberOfCardValues() > 0;
} }
get hasIdentityValues() { get hasIdentityValues() {
return ( return this.getNumberOfIdentityValues() > 0;
!!this.cipher.identity.fullAddressForCopy ||
!!this.cipher.identity.email ||
!!this.cipher.identity.username ||
!!this.cipher.identity.phone
);
} }
get hasSecureNoteValue() { get hasSecureNoteValue() {
return !!this.cipher.notes; return this.getNumberOfSecureNoteValues() > 0;
} }
get hasSshKeyValues() { get hasSshKeyValues() {
return ( return this.getNumberOfSshKeyValues() > 0;
!!this.cipher.sshKey.privateKey ||
!!this.cipher.sshKey.publicKey ||
!!this.cipher.sshKey.keyFingerprint
);
} }
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService) {}
/** Sets the number of populated login values for the cipher */
private getNumberOfLoginValues() {
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
const copyableLoginFields: CopyableCipherFields[] = [
"LoginUsername",
"LoginPassword",
"LoginTotp",
];
return this.cipher.copyableFields.filter((field) => copyableLoginFields.includes(field))
.length;
}
return [this.cipher.login.username, this.cipher.login.password, this.cipher.login.totp].filter(
Boolean,
).length;
}
/** Sets the number of populated card values for the cipher */
private getNumberOfCardValues() {
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
const copyableCardFields: CopyableCipherFields[] = ["CardSecurityCode", "CardNumber"];
return this.cipher.copyableFields.filter((field) => copyableCardFields.includes(field))
.length;
}
return [this.cipher.card.code, this.cipher.card.number].filter(Boolean).length;
}
/** Sets the number of populated identity values for the cipher */
private getNumberOfIdentityValues() {
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
const copyableIdentityFields: CopyableCipherFields[] = [
"IdentityAddress",
"IdentityEmail",
"IdentityUsername",
"IdentityPhone",
];
return this.cipher.copyableFields.filter((field) => copyableIdentityFields.includes(field))
.length;
}
return [
this.cipher.identity.fullAddressForCopy,
this.cipher.identity.email,
this.cipher.identity.username,
this.cipher.identity.phone,
].filter(Boolean).length;
}
/** Sets the number of populated secure note values for the cipher */
private getNumberOfSecureNoteValues(): number {
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
return this.cipher.copyableFields.includes("SecureNotes") ? 1 : 0;
}
return this.cipher.notes ? 1 : 0;
}
/** Sets the number of populated SSH key values for the cipher */
private getNumberOfSshKeyValues() {
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
return this.cipher.copyableFields.includes("SshKey") ? 1 : 0;
}
return [
this.cipher.sshKey.privateKey,
this.cipher.sshKey.publicKey,
this.cipher.sshKey.keyFingerprint,
].filter(Boolean).length;
}
} }

View File

@@ -5,7 +5,7 @@
size="small" size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name" [attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name" [title]="'moreOptionsTitle' | i18n: cipher.name"
[disabled]="cipher.decryptionFailure" [disabled]="decryptionFailure"
[bitMenuTriggerFor]="moreOptions" [bitMenuTriggerFor]="moreOptions"
></button> ></button>
<bit-menu #moreOptions> <bit-menu #moreOptions>

View File

@@ -14,8 +14,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; 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 { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { import {
DialogService, DialogService,
IconButtonModule, IconButtonModule,
@@ -34,12 +37,12 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
}) })
export class ItemMoreOptionsComponent { export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherView>(undefined); private _cipher$ = new BehaviorSubject<CipherViewLike>(undefined);
@Input({ @Input({
required: true, required: true,
}) })
set cipher(c: CipherView) { set cipher(c: CipherViewLike) {
this._cipher$.next(c); this._cipher$.next(c);
} }
@@ -109,17 +112,22 @@ export class ItemMoreOptionsComponent {
get canViewPassword() { get canViewPassword() {
return this.cipher.viewPassword; return this.cipher.viewPassword;
} }
get decryptionFailure() {
return CipherViewLikeUtils.decryptionFailure(this.cipher);
}
/** /**
* Determines if the cipher can be autofilled. * Determines if the cipher can be autofilled.
*/ */
get canAutofill() { get canAutofill() {
return ([CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]).includes( return ([CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]).includes(
this.cipher.type, CipherViewLikeUtils.getType(this.cipher),
); );
} }
get isLogin() { get isLogin() {
return this.cipher.type === CipherType.Login; return CipherViewLikeUtils.getType(this.cipher) === CipherType.Login;
} }
get favoriteText() { get favoriteText() {
@@ -127,11 +135,13 @@ export class ItemMoreOptionsComponent {
} }
async doAutofill() { async doAutofill() {
await this.vaultPopupAutofillService.doAutofill(this.cipher); const cipher = await this.cipherService.getFullCipherView(this.cipher);
await this.vaultPopupAutofillService.doAutofill(cipher);
} }
async doAutofillAndSave() { 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() { async onView() {
@@ -140,7 +150,7 @@ export class ItemMoreOptionsComponent {
return; return;
} }
await this.router.navigate(["/view-cipher"], { 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. * Toggles the favorite status of the cipher and updates it on the server.
*/ */
async toggleFavorite() { async toggleFavorite() {
this.cipher.favorite = !this.cipher.favorite; const cipher = await this.cipherService.getFullCipherView(this.cipher);
cipher.favorite = !cipher.favorite;
const activeUserId = await firstValueFrom( const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)), 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); await this.cipherService.updateWithServer(encryptedCipher);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
@@ -176,7 +189,7 @@ export class ItemMoreOptionsComponent {
return; return;
} }
if (this.cipher.login?.hasFido2Credentials) { if (CipherViewLikeUtils.hasFido2Credentials(this.cipher)) {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" }, title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" }, content: { key: "passkeyNotCopiedAlert" },
@@ -192,7 +205,7 @@ export class ItemMoreOptionsComponent {
queryParams: { queryParams: {
clone: true.toString(), clone: true.toString(),
cipherId: this.cipher.id, cipherId: this.cipher.id,
type: this.cipher.type.toString(), type: CipherViewLikeUtils.getType(this.cipher).toString(),
} as AddEditQueryParams, } as AddEditQueryParams,
}); });
} }

View File

@@ -97,7 +97,8 @@
(click)="primaryActionOnSelect(cipher)" (click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)" (dblclick)="launchCipher(cipher)"
[appA11yTitle]=" [appA11yTitle]="
cipherItemTitleKey()(cipher) | i18n: cipher.name : cipher.login.username cipherItemTitleKey()(cipher)
| i18n: cipher.name : CipherViewLikeUtils.getLogin(cipher)?.username
" "
class="{{ itemHeightClass }}" class="{{ itemHeightClass }}"
> >
@@ -114,11 +115,11 @@
[appA11yTitle]="orgIconTooltip(cipher)" [appA11yTitle]="orgIconTooltip(cipher)"
></i> ></i>
<i <i
*ngIf="cipher.hasAttachments" *ngIf="CipherViewLikeUtils.hasAttachments(cipher)"
class="bwi bwi-paperclip bwi-sm" class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n" [appA11yTitle]="'attachments' | i18n"
></i> ></i>
<span slot="secondary">{{ cipher.subTitle }}</span> <span slot="secondary">{{ CipherViewLikeUtils.subtitle(cipher) }}</span>
</button> </button>
<ng-container slot="end"> <ng-container slot="end">
@@ -134,7 +135,7 @@
{{ "fill" | i18n }} {{ "fill" | i18n }}
</button> </button>
</bit-item-action> </bit-item-action>
<bit-item-action *ngIf="!showAutofillButton() && cipher.canLaunch"> <bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<button <button
type="button" type="button"
bitIconButton="bwi-external-link" bitIconButton="bwi-external-link"

View File

@@ -26,7 +26,10 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { CipherId } from "@bitwarden/common/types/guid"; import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { import {
BadgeModule, BadgeModule,
ButtonModule, ButtonModule,
@@ -54,7 +57,7 @@ import {
VaultPopupSectionService, VaultPopupSectionService,
PopupSectionOpen, PopupSectionOpen,
} from "../../../services/vault-popup-section.service"; } from "../../../services/vault-popup-section.service";
import { PopupCipherView } from "../../../views/popup-cipher.view"; import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
@@ -84,6 +87,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
export class VaultListItemsContainerComponent implements AfterViewInit { export class VaultListItemsContainerComponent implements AfterViewInit {
private compactModeService = inject(CompactModeService); private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService); private vaultPopupSectionService = inject(VaultPopupSectionService);
protected CipherViewLikeUtils = CipherViewLikeUtils;
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport;
@ViewChild(DisclosureComponent) disclosure!: DisclosureComponent; @ViewChild(DisclosureComponent) disclosure!: DisclosureComponent;
@@ -125,7 +129,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/ */
private viewCipherTimeout?: number; private viewCipherTimeout?: number;
ciphers = input<PopupCipherView[]>([]); ciphers = input<PopupCipherViewLike[]>([]);
/** /**
* If true, we will group ciphers by type (Login, Card, Identity) * If true, we will group ciphers by type (Login, Card, Identity)
@@ -139,7 +143,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
cipherGroups = computed< cipherGroups = computed<
{ {
subHeaderKey?: string; subHeaderKey?: string;
ciphers: PopupCipherView[]; ciphers: PopupCipherViewLike[];
}[] }[]
>(() => { >(() => {
// Not grouping by type, return a single group with all ciphers // Not grouping by type, return a single group with all ciphers
@@ -147,11 +151,11 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return [{ ciphers: this.ciphers() }]; return [{ ciphers: this.ciphers() }];
} }
const groups: Record<string, PopupCipherView[]> = {}; const groups: Record<string, PopupCipherViewLike[]> = {};
this.ciphers().forEach((cipher) => { this.ciphers().forEach((cipher) => {
let groupKey = "all"; let groupKey = "all";
switch (cipher.type) { switch (CipherViewLikeUtils.getType(cipher)) {
case CipherType.Card: case CipherType.Card:
groupKey = "cards"; groupKey = "cards";
break; break;
@@ -212,8 +216,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* Resolved i18n key to use for suggested cipher items * Resolved i18n key to use for suggested cipher items
*/ */
cipherItemTitleKey = computed(() => { cipherItemTitleKey = computed(() => {
return (cipher: CipherView) => { return (cipher: CipherViewLike) => {
const hasUsername = cipher.login?.username != null; const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
const key = const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked() this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle" ? "autofillTitle"
@@ -259,12 +264,12 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The tooltip text for the organization icon for ciphers that belong to an organization. * The tooltip text for the organization icon for ciphers that belong to an organization.
* @param cipher * @param cipher
*/ */
orgIconTooltip(cipher: PopupCipherView) { orgIconTooltip({ collectionIds, collections }: PopupCipherViewLike) {
if (cipher.collectionIds.length > 1 || !cipher.collections) { if (collectionIds.length > 1 || !collections) {
return this.i18nService.t("nCollections", cipher.collectionIds.length); return this.i18nService.t("nCollections", collectionIds.length);
} }
return cipher.collections[0]?.name; return collections[0]?.name;
} }
protected autofillShortcutTooltip = signal<string | undefined>(undefined); protected autofillShortcutTooltip = signal<string | undefined>(undefined);
@@ -292,7 +297,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
} }
} }
primaryActionOnSelect(cipher: CipherView) { primaryActionOnSelect(cipher: PopupCipherViewLike) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked() return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher) ? this.doAutofill(cipher)
: this.onViewCipher(cipher); : this.onViewCipher(cipher);
@@ -301,8 +306,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/** /**
* Launches the login cipher in a new browser tab. * Launches the login cipher in a new browser tab.
*/ */
async launchCipher(cipher: CipherView) { async launchCipher(cipher: CipherViewLike) {
if (!cipher.canLaunch) { const launchURI = CipherViewLikeUtils.getLaunchUri(cipher);
if (!CipherViewLikeUtils.canLaunch(cipher) || !launchURI) {
return; return;
} }
@@ -313,20 +319,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
} }
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.updateLastLaunchedDate(cipher.id, activeUserId); await this.cipherService.updateLastLaunchedDate(cipher.id!, activeUserId);
await BrowserApi.createNewTab(cipher.login.launchUri); await BrowserApi.createNewTab(launchURI);
if (BrowserPopupUtils.inPopup(window)) { if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window); BrowserApi.closePopup(window);
} }
} }
async doAutofill(cipher: PopupCipherView) { async doAutofill(cipher: PopupCipherViewLike) {
await this.vaultPopupAutofillService.doAutofill(cipher); if (!CipherViewLikeUtils.isCipherListView(cipher)) {
await this.vaultPopupAutofillService.doAutofill(cipher);
return;
}
// When only the `CipherListView` is available, fetch the full cipher details
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);
await this.vaultPopupAutofillService.doAutofill(cipherView);
} }
async onViewCipher(cipher: PopupCipherView) { async onViewCipher(cipher: PopupCipherViewLike) {
// We already have a view action in progress, don't start another // We already have a view action in progress, don't start another
if (this.viewCipherTimeout != null) { if (this.viewCipherTimeout != null) {
return; return;
@@ -336,7 +352,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
this.viewCipherTimeout = window.setTimeout( this.viewCipherTimeout = window.setTimeout(
async () => { async () => {
try { try {
if (cipher.decryptionFailure) { if (CipherViewLikeUtils.decryptionFailure(cipher)) {
DecryptionFailureDialogComponent.open(this.dialogService, { DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId], cipherIds: [cipher.id as CipherId],
}); });
@@ -355,7 +371,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
this.viewCipherTimeout = undefined; this.viewCipherTimeout = undefined;
} }
}, },
cipher.canLaunch ? 200 : 0, CipherViewLikeUtils.canLaunch(cipher) ? 200 : 0,
); );
} }

View File

@@ -1,7 +1,7 @@
import { WritableSignal, signal } from "@angular/core"; import { WritableSignal, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing"; import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -23,10 +23,12 @@ import {
RestrictedCipherType, RestrictedCipherType,
RestrictedItemTypesService, RestrictedItemTypesService,
} from "@bitwarden/common/vault/services/restricted-item-types.service"; } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service"; import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import { PopupCipherViewLike } from "../views/popup-cipher.view";
import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
import { VaultPopupItemsService } from "./vault-popup-items.service"; import { VaultPopupItemsService } from "./vault-popup-items.service";
@@ -80,7 +82,9 @@ describe("VaultPopupItemsService", () => {
cipherList[2].favorite = true; cipherList[2].favorite = true;
cipherList[3].favorite = true; cipherList[3].favorite = true;
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); const cipherList$ = new BehaviorSubject<CipherView[]>(cipherList);
cipherServiceMock.cipherListViews$.mockReturnValue(cipherList$.asObservable());
ciphersSubject = new BehaviorSubject<Record<CipherId, CipherData>>({}); ciphersSubject = new BehaviorSubject<Record<CipherId, CipherData>>({});
localDataSubject = new BehaviorSubject<Record<CipherId, LocalData>>({}); localDataSubject = new BehaviorSubject<Record<CipherId, LocalData>>({});
@@ -111,7 +115,7 @@ describe("VaultPopupItemsService", () => {
}); });
// Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService` // Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService`
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers, (ciphers: PopupCipherViewLike[]) => ciphers,
); );
vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({ vaultAutofillServiceMock.currentAutofillTab$ = new BehaviorSubject({
@@ -279,7 +283,9 @@ describe("VaultPopupItemsService", () => {
const current = ciphers[i]; const current = ciphers[i];
const next = ciphers[i + 1]; const next = ciphers[i + 1];
expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]); expect(expectedTypeOrder[CipherViewLikeUtils.getType(current)]).toBeLessThanOrEqual(
expectedTypeOrder[CipherViewLikeUtils.getType(next)],
);
} }
expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled(); expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled();
done(); done();
@@ -365,28 +371,34 @@ describe("VaultPopupItemsService", () => {
describe("emptyVault$", () => { describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => { it("should return true if there are no ciphers", (done) => {
cipherServiceMock.getAllDecrypted.mockResolvedValue([]); cipherServiceMock.cipherListViews$.mockReturnValue(of([]));
service.emptyVault$.subscribe((empty) => { service.emptyVault$.pipe(take(1)).subscribe((empty) => {
expect(empty).toBe(true); expect(empty).toBe(true);
done(); done();
}); });
}); });
it("should return false if there are ciphers", (done) => { it("should return false if there are ciphers", (done) => {
service.emptyVault$.subscribe((empty) => { cipherServiceMock.cipherListViews$.mockReturnValue(
of([{ id: "1", type: CipherType.Login, name: "Login 1" }] as CipherView[]),
);
service.emptyVault$.pipe(take(1)).subscribe((empty) => {
expect(empty).toBe(false); expect(empty).toBe(false);
done(); done();
}); });
}); });
it("should return true when all ciphers are deleted", (done) => { it("should return true when all ciphers are deleted", (done) => {
cipherServiceMock.getAllDecrypted.mockResolvedValue([ cipherServiceMock.cipherListViews$.mockReturnValue(
{ id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true }, of([
{ id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true }, { id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true }, { id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true },
] as CipherView[]); { id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true },
] as CipherView[]),
);
service.emptyVault$.subscribe((empty) => { service.emptyVault$.pipe(take(1)).subscribe((empty) => {
expect(empty).toBe(true); expect(empty).toBe(true);
done(); done();
}); });
@@ -416,8 +428,7 @@ describe("VaultPopupItemsService", () => {
deletedCipher.deletedDate = new Date(); deletedCipher.deletedDate = new Date();
const ciphers = [new CipherView(), new CipherView(), new CipherView(), deletedCipher]; const ciphers = [new CipherView(), new CipherView(), new CipherView(), deletedCipher];
cipherServiceMock.getAllDecrypted.mockResolvedValue(ciphers); cipherServiceMock.cipherListViews$.mockReturnValue(of(ciphers));
ciphersSubject.next({}); ciphersSubject.next({});
const deletedCiphers = await firstValueFrom(service.deletedCiphers$); const deletedCiphers = await firstValueFrom(service.deletedCiphers$);

View File

@@ -23,20 +23,22 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import { waitUntil } from "../../util"; import { waitUntil } from "../../util";
import { PopupCipherView } from "../views/popup-cipher.view"; import { PopupCipherViewLike } from "../views/popup-cipher.view";
import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
@@ -96,49 +98,52 @@ export class VaultPopupItemsService {
* Observable that contains the list of all decrypted ciphers. * Observable that contains the list of all decrypted ciphers.
* @private * @private
*/ */
private _allDecryptedCiphers$: Observable<CipherView[]> = this.accountService.activeAccount$.pipe( private _allDecryptedCiphers$: Observable<CipherViewLike[]> =
map((a) => a?.id), this.accountService.activeAccount$.pipe(
filter((userId): userId is UserId => userId != null), map((a) => a?.id),
switchMap((userId) => filter((userId): userId is UserId => userId != null),
merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe( switchMap((userId) =>
runInsideAngular(this.ngZone), merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe(
tap(() => this._ciphersLoading$.next()), runInsideAngular(this.ngZone),
waitUntilSync(this.syncService), tap(() => this._ciphersLoading$.next()),
switchMap(() => waitUntilSync(this.syncService),
combineLatest([ switchMap(() =>
Utils.asyncToObservable(() => this.cipherService.getAllDecrypted(userId)), combineLatest([
this.cipherService.failedToDecryptCiphers$(userId), this.cipherService
this.restrictedItemTypesService.restricted$.pipe(startWith([])), .cipherListViews$(userId)
]), .pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId),
this.restrictedItemTypesService.restricted$.pipe(startWith([])),
]),
),
map(([ciphers, failedToDecryptCiphers, restrictions]) => {
const allCiphers = [...(failedToDecryptCiphers || []), ...ciphers];
return allCiphers.filter(
(cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restrictions),
);
}),
), ),
map(([ciphers, failedToDecryptCiphers, restrictions]) => {
const allCiphers = [...(failedToDecryptCiphers || []), ...ciphers];
return allCiphers.filter(
(cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restrictions),
);
}),
), ),
), shareReplay({ refCount: true, bufferSize: 1 }),
shareReplay({ refCount: true, bufferSize: 1 }), );
);
private _activeCipherList$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe( private _activeCipherList$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) => switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe(
map(([organizations, collections]) => { map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers return ciphers
.filter((c) => !c.isDeleted) .filter((c) => !CipherViewLikeUtils.isDeleted(c))
.map( .map((cipher) => {
(cipher) => (cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map(
new PopupCipherView( (colId) => collectionMap[colId as CollectionId],
cipher, );
cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), (cipher as PopupCipherViewLike).organization =
orgMap[cipher.organizationId as OrganizationId], orgMap[cipher.organizationId as OrganizationId];
), return cipher;
); });
}), }),
), ),
), ),
@@ -157,21 +162,23 @@ export class VaultPopupItemsService {
}), }),
); );
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([ private _filteredCipherList$: Observable<PopupCipherViewLike[]> = combineLatest([
this._activeCipherList$, this._activeCipherList$,
this.searchText$, this.searchText$,
this.vaultPopupListFiltersService.filterFunction$, this.vaultPopupListFiltersService.filterFunction$,
getUserId(this.accountService.activeAccount$), getUserId(this.accountService.activeAccount$),
]).pipe( ]).pipe(
map(([ciphers, searchText, filterFunction, userId]): [CipherView[], string, UserId] => [ map(
filterFunction(ciphers), ([ciphers, searchText, filterFunction, userId]): [PopupCipherViewLike[], string, UserId] => [
searchText, filterFunction(ciphers),
userId, searchText,
]), userId,
],
),
switchMap( switchMap(
([ciphers, searchText, userId]) => ([ciphers, searchText, userId]) =>
this.searchService.searchCiphers(userId, searchText, undefined, ciphers) as Promise< this.searchService.searchCiphers(userId, searchText, undefined, ciphers) as Promise<
PopupCipherView[] PopupCipherViewLike[]
>, >,
), ),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
@@ -183,7 +190,7 @@ export class VaultPopupItemsService {
* *
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
*/ */
autoFillCiphers$: Observable<PopupCipherView[]> = combineLatest([ autoFillCiphers$: Observable<PopupCipherViewLike[]> = combineLatest([
this._filteredCipherList$, this._filteredCipherList$,
this._otherAutoFillTypes$, this._otherAutoFillTypes$,
this.vaultPopupAutofillService.currentAutofillTab$, this.vaultPopupAutofillService.currentAutofillTab$,
@@ -202,7 +209,7 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill. * List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by name. * Ciphers are sorted by name.
*/ */
favoriteCiphers$: Observable<PopupCipherView[]> = this.autoFillCiphers$.pipe( favoriteCiphers$: Observable<PopupCipherViewLike[]> = this.autoFillCiphers$.pipe(
withLatestFrom(this._filteredCipherList$), withLatestFrom(this._filteredCipherList$),
map(([autoFillCiphers, ciphers]) => map(([autoFillCiphers, ciphers]) =>
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
@@ -214,7 +221,7 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name. * Ciphers are sorted by name.
*/ */
remainingCiphers$: Observable<PopupCipherView[]> = this.favoriteCiphers$.pipe( remainingCiphers$: Observable<PopupCipherViewLike[]> = this.favoriteCiphers$.pipe(
concatMap( concatMap(
( (
favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$ favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$
@@ -282,21 +289,23 @@ export class VaultPopupItemsService {
/** /**
* Observable that contains the list of ciphers that have been deleted. * Observable that contains the list of ciphers that have been deleted.
*/ */
deletedCiphers$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe( deletedCiphers$: Observable<PopupCipherViewLike[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) => switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe(
map(([organizations, collections]) => { map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers return ciphers
.filter((c) => c.isDeleted) .filter((c) => CipherViewLikeUtils.isDeleted(c))
.map( .map(
(cipher) => (cipher) =>
new PopupCipherView( ({
cipher, ...cipher,
cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), collections: cipher.collectionIds?.map(
orgMap[cipher.organizationId as OrganizationId], (colId) => collectionMap[colId as CollectionId],
), ),
organization: orgMap[cipher.organizationId as OrganizationId],
}) as PopupCipherViewLike,
); );
}), }),
), ),
@@ -327,7 +336,7 @@ export class VaultPopupItemsService {
* Sorts by type, then by last used date, and finally by name. * Sorts by type, then by last used date, and finally by name.
* @private * @private
*/ */
private sortCiphersForAutofill(a: CipherView, b: CipherView): number { private sortCiphersForAutofill(a: CipherViewLike, b: CipherViewLike): number {
const typeOrder = { const typeOrder = {
[CipherType.Login]: 1, [CipherType.Login]: 1,
[CipherType.Card]: 2, [CipherType.Card]: 2,
@@ -336,10 +345,13 @@ export class VaultPopupItemsService {
[CipherType.SshKey]: 5, [CipherType.SshKey]: 5,
} as Record<CipherType, number>; } as Record<CipherType, number>;
const aType = CipherViewLikeUtils.getType(a);
const bType = CipherViewLikeUtils.getType(b);
// Compare types first // Compare types first
if (typeOrder[a.type] < typeOrder[b.type]) { if (typeOrder[aType] < typeOrder[bType]) {
return -1; return -1;
} else if (typeOrder[a.type] > typeOrder[b.type]) { } else if (typeOrder[aType] > typeOrder[bType]) {
return 1; return 1;
} }

View File

@@ -27,6 +27,8 @@ import {
RestrictedItemTypesService, RestrictedItemTypesService,
} from "@bitwarden/common/vault/services/restricted-item-types.service"; } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { PopupCipherViewLike } from "../views/popup-cipher.view";
import { import {
CachedFilterState, CachedFilterState,
MY_VAULT_ID, MY_VAULT_ID,
@@ -47,7 +49,7 @@ describe("VaultPopupListFiltersService", () => {
const memberOrganizations$ = (userId: UserId) => _memberOrganizations$; const memberOrganizations$ = (userId: UserId) => _memberOrganizations$;
const organizations$ = new BehaviorSubject<Organization[]>([]); const organizations$ = new BehaviorSubject<Organization[]>([]);
let folderViews$ = new BehaviorSubject([]); let folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({}); const cipherListViews$ = new BehaviorSubject({});
let decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]); let decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const policyAppliesToUser$ = new BehaviorSubject<boolean>(false); const policyAppliesToUser$ = new BehaviorSubject<boolean>(false);
let viewCacheService: { let viewCacheService: {
@@ -65,7 +67,7 @@ describe("VaultPopupListFiltersService", () => {
} as unknown as FolderService; } as unknown as FolderService;
const cipherService = { const cipherService = {
cipherViews$: () => cipherViews$, cipherListViews$: () => cipherListViews$,
} as unknown as CipherService; } as unknown as CipherService;
const organizationService = { const organizationService = {
@@ -508,7 +510,7 @@ describe("VaultPopupListFiltersService", () => {
{ id: "2345", name: "Folder 2" }, { id: "2345", name: "Folder 2" },
]); ]);
cipherViews$.next({ cipherListViews$.next({
"1": { folderId: "1234", organizationId: "1234" }, "1": { folderId: "1234", organizationId: "1234" },
"2": { folderId: "2345", organizationId: "56789" }, "2": { folderId: "2345", organizationId: "56789" },
}); });
@@ -566,6 +568,28 @@ describe("VaultPopupListFiltersService", () => {
service.filterForm.patchValue({ organization }); service.filterForm.patchValue({ organization });
}); });
it("keeps ciphers with null and undefined for organizationId when MyVault is selected", (done) => {
const organization = { id: MY_VAULT_ID } as Organization;
const undefinedOrgIdCipher = {
type: CipherType.SecureNote,
collectionIds: [],
organizationId: undefined,
} as unknown as PopupCipherViewLike;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction([...ciphers, undefinedOrgIdCipher])).toEqual([
ciphers[0],
ciphers[2],
ciphers[3],
undefinedOrgIdCipher,
]);
done();
});
service.filterForm.patchValue({ organization });
});
it("filters out ciphers that do not belong to the selected organization", (done) => { it("filters out ciphers that do not belong to the selected organization", (done) => {
const organization = { id: "8978" } as Organization; const organization = { id: "8978" } as Organization;
@@ -717,7 +741,10 @@ function createSeededVaultPopupListFiltersService(
collections: CollectionView[], collections: CollectionView[],
folderViews: FolderView[], folderViews: FolderView[],
cachedState: CachedFilterState = {}, cachedState: CachedFilterState = {},
): { service: VaultPopupListFiltersService; cachedSignal: WritableSignal<CachedFilterState> } { ): {
service: VaultPopupListFiltersService;
cachedSignal: WritableSignal<CachedFilterState>;
} {
const seededMemberOrganizations$ = new BehaviorSubject<Organization[]>(organizations); const seededMemberOrganizations$ = new BehaviorSubject<Organization[]>(organizations);
const seededCollections$ = new BehaviorSubject<CollectionView[]>(collections); const seededCollections$ = new BehaviorSubject<CollectionView[]>(collections);
const seededFolderViews$ = new BehaviorSubject<FolderView[]>(folderViews); const seededFolderViews$ = new BehaviorSubject<FolderView[]>(folderViews);
@@ -744,7 +771,7 @@ function createSeededVaultPopupListFiltersService(
} as any; } as any;
const cipherServiceMock = { const cipherServiceMock = {
cipherViews$: () => new BehaviorSubject({}), cipherListViews$: () => new BehaviorSubject({}),
} as any; } as any;
const i18nServiceMock = { const i18nServiceMock = {

View File

@@ -40,13 +40,15 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { ChipSelectOption } from "@bitwarden/components"; import { ChipSelectOption } from "@bitwarden/components";
import { PopupCipherViewLike } from "../views/popup-cipher.view";
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", { const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
deserializer: (obj) => obj, deserializer: (obj) => obj,
}); });
@@ -111,7 +113,7 @@ export class VaultPopupListFiltersService {
/** /**
* Static list of ciphers views used in synchronous context * Static list of ciphers views used in synchronous context
*/ */
private cipherViews: CipherView[] = []; private cipherViews: PopupCipherViewLike[] = [];
private activeUserId$ = this.accountService.activeAccount$.pipe( private activeUserId$ = this.accountService.activeAccount$.pipe(
map((a) => a?.id), map((a) => a?.id),
@@ -216,21 +218,22 @@ export class VaultPopupListFiltersService {
filterVisibilityState$ = this.filterVisibilityState.state$; filterVisibilityState$ = this.filterVisibilityState.state$;
/** /**
* Observable whose value is a function that filters an array of `CipherView` objects based on the current filters * Observable whose value is a function that filters an array of `PopupCipherViewLike` objects based on the current filters
*/ */
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = combineLatest([ filterFunction$: Observable<(ciphers: PopupCipherViewLike[]) => PopupCipherViewLike[]> =
this.filters$, this.filters$.pipe(
]).pipe( map(
map( (filters) => (ciphers: PopupCipherViewLike[]) =>
([filters]) =>
(ciphers: CipherView[]) =>
ciphers.filter((cipher) => { ciphers.filter((cipher) => {
// Vault popup lists never shows deleted ciphers // Vault popup lists never shows deleted ciphers
if (cipher.isDeleted) { if (CipherViewLikeUtils.isDeleted(cipher)) {
return false; return false;
} }
if (filters.cipherType !== null && cipher.type !== filters.cipherType) { if (
filters.cipherType !== null &&
CipherViewLikeUtils.getType(cipher) !== filters.cipherType
) {
return false; return false;
} }
@@ -245,7 +248,7 @@ export class VaultPopupListFiltersService {
const isMyVault = filters.organization?.id === MY_VAULT_ID; const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) { if (isMyVault) {
if (cipher.organizationId !== null) { if (cipher.organizationId != null) {
return false; return false;
} }
} else if (filters.organization) { } else if (filters.organization) {
@@ -256,8 +259,8 @@ export class VaultPopupListFiltersService {
return true; return true;
}), }),
), ),
); );
/** /**
* All available cipher types (filtered by policy restrictions) * All available cipher types (filtered by policy restrictions)
@@ -356,7 +359,7 @@ export class VaultPopupListFiltersService {
folders$: Observable<ChipSelectOption<FolderView>[]> = this.activeUserId$.pipe( folders$: Observable<ChipSelectOption<FolderView>[]> = this.activeUserId$.pipe(
switchMap((userId) => { switchMap((userId) => {
// Observable of cipher views // Observable of cipher views
const cipherViews$ = this.cipherService.cipherViews$(userId).pipe( const cipherViews$ = this.cipherService.cipherListViews$(userId).pipe(
map((ciphers) => { map((ciphers) => {
this.cipherViews = ciphers ? Object.values(ciphers) : []; this.cipherViews = ciphers ? Object.values(ciphers) : [];
return this.cipherViews; return this.cipherViews;
@@ -374,30 +377,36 @@ export class VaultPopupListFiltersService {
this.folderService.folderViews$(userId), this.folderService.folderViews$(userId),
cipherViews$, cipherViews$,
]).pipe( ]).pipe(
map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { map(
if (folders.length === 1 && folders[0].id === null) { ([filters, folders, cipherViews]): [
// Do not display folder selections when only the "no folder" option is available. PopupListFilter,
return [filters as PopupListFilter, [], cipherViews]; FolderView[],
} PopupCipherViewLike[],
] => {
if (folders.length === 1 && folders[0].id === null) {
// Do not display folder selections when only the "no folder" option is available.
return [filters as PopupListFilter, [], cipherViews];
}
// Sort folders by alphabetic name // Sort folders by alphabetic name
folders.sort(Utils.getSortFunction(this.i18nService, "name")); folders.sort(Utils.getSortFunction(this.i18nService, "name"));
let arrangedFolders = folders; let arrangedFolders = folders;
const noFolder = folders.find((f) => f.id === null); const noFolder = folders.find((f) => f.id === null);
if (noFolder) { if (noFolder) {
// Update `name` of the "no folder" option to "Items with no folder" // Update `name` of the "no folder" option to "Items with no folder"
const updatedNoFolder = { const updatedNoFolder = {
...noFolder, ...noFolder,
name: this.i18nService.t("itemsWithNoFolder"), name: this.i18nService.t("itemsWithNoFolder"),
}; };
// Move the "no folder" option to the end of the list // Move the "no folder" option to the end of the list
arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder];
} }
return [filters as PopupListFilter, arrangedFolders, cipherViews]; return [filters as PopupListFilter, arrangedFolders, cipherViews];
}), },
),
map(([filters, folders, cipherViews]) => { map(([filters, folders, cipherViews]) => {
const organizationId = filters.organization?.id ?? null; const organizationId = filters.organization?.id ?? null;

View File

@@ -30,7 +30,7 @@ import {
PasswordRepromptService, PasswordRepromptService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { PopupCipherView } from "../../views/popup-cipher.view"; import { PopupCipherViewLike } from "../../views/popup-cipher.view";
@Component({ @Component({
selector: "app-trash-list-items-container", selector: "app-trash-list-items-container",
@@ -54,7 +54,7 @@ export class TrashListItemsContainerComponent {
* The list of trashed items to display. * The list of trashed items to display.
*/ */
@Input() @Input()
ciphers: PopupCipherView[] = []; ciphers: PopupCipherViewLike[] = [];
@Input() @Input()
headerText: string; headerText: string;
@@ -73,12 +73,12 @@ export class TrashListItemsContainerComponent {
/** /**
* The tooltip text for the organization icon for ciphers that belong to an organization. * The tooltip text for the organization icon for ciphers that belong to an organization.
*/ */
orgIconTooltip(cipher: PopupCipherView) { orgIconTooltip({ collections, collectionIds }: PopupCipherViewLike) {
if (cipher.collectionIds.length > 1) { if (collectionIds.length > 1) {
return this.i18nService.t("nCollections", cipher.collectionIds.length); return this.i18nService.t("nCollections", collectionIds.length);
} }
return cipher.collections[0]?.name; return collections[0]?.name;
} }
async restore(cipher: CipherView) { async restore(cipher: CipherView) {

View File

@@ -1,25 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CollectionView } from "@bitwarden/admin-console/common"; import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherListView } from "@bitwarden/sdk-internal";
/** interface CommonPopupCipherView {
* Extended cipher view for the popup. Includes the associated collections and organization
* if applicable.
*/
export class PopupCipherView extends CipherView {
collections?: CollectionView[]; collections?: CollectionView[];
organization?: Organization; organization?: Organization;
constructor(
cipher: CipherView,
collections: CollectionView[] = null,
organization: Organization = null,
) {
super();
Object.assign(this, cipher);
this.collections = collections;
this.organization = organization;
}
} }
/** Extended view for the popup based off of `CipherView` */
interface PopupCipherView extends CipherView, CommonPopupCipherView {}
/** Extended view for the popup based off of `CipherListView` from the SDK. */
interface PopupCipherListView extends CipherListView, CommonPopupCipherView {}
export type PopupCipherViewLike = PopupCipherListView | PopupCipherView;

View File

@@ -572,6 +572,20 @@
"copyVerificationCodeTotp": { "copyVerificationCodeTotp": {
"message": "Copy verification code (TOTP)" "message": "Copy verification code (TOTP)"
}, },
"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"
}
}
},
"length": { "length": {
"message": "Length" "message": "Length"
}, },
@@ -1425,6 +1439,9 @@
"message": "Copy security code", "message": "Copy security code",
"description": "Copy credit card security code (CVV)" "description": "Copy credit card security code (CVV)"
}, },
"cardNumber": {
"message": "card number"
},
"premiumMembership": { "premiumMembership": {
"message": "Premium membership" "message": "Premium membership"
}, },

View File

@@ -34,7 +34,7 @@
></i> ></i>
<span class="sr-only">{{ "shared" | i18n }}</span> <span class="sr-only">{{ "shared" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="c.hasAttachments"> <ng-container *ngIf="CipherViewLikeUtils.hasAttachments(c)">
<i <i
class="bwi bwi-paperclip text-muted" class="bwi bwi-paperclip text-muted"
title="{{ 'attachments' | i18n }}" title="{{ 'attachments' | i18n }}"
@@ -44,7 +44,9 @@
</ng-container> </ng-container>
</span> </span>
</span> </span>
<span *ngIf="c.subTitle" class="detail">{{ c.subTitle }}</span> <span *ngIf="CipherViewLikeUtils.subtitle(c)" class="detail">{{
CipherViewLikeUtils.subtitle(c)
}}</span>
</div> </div>
</button> </button>
</div> </div>

View File

@@ -9,8 +9,11 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.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 { 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 { MenuModule } from "@bitwarden/components";
import { SearchBarService } from "../../../app/layout/search/search-bar.service"; 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", templateUrl: "vault-items-v2.component.html",
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule], imports: [MenuModule, CommonModule, JslibModule, ScrollingModule],
}) })
export class VaultItemsV2Component extends BaseVaultItemsComponent { export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
protected CipherViewLikeUtils = CipherViewLikeUtils;
constructor( constructor(
searchService: SearchService, searchService: SearchService,
private readonly searchBarService: SearchBarService, private readonly searchBarService: SearchBarService,
@@ -37,7 +41,7 @@ export class VaultItemsV2Component extends BaseVaultItemsComponent {
}); });
} }
trackByFn(index: number, c: CipherView): string { trackByFn(index: number, c: C): string {
return c.id; return c.id!;
} }
} }

View File

@@ -40,6 +40,10 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions
import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { import {
BadgeModule, BadgeModule,
ButtonModule, ButtonModule,
@@ -124,9 +128,11 @@ const BroadcasterSubscriptionId = "VaultComponent";
}, },
], ],
}) })
export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { export class VaultV2Component<C extends CipherViewLike>
implements OnInit, OnDestroy, CopyClickListener
{
@ViewChild(VaultItemsV2Component, { static: true }) @ViewChild(VaultItemsV2Component, { static: true })
vaultItemsComponent: VaultItemsV2Component | null = null; vaultItemsComponent: VaultItemsV2Component<C> | null = null;
@ViewChild(VaultFilterComponent, { static: true }) @ViewChild(VaultFilterComponent, { static: true })
vaultFilterComponent: VaultFilterComponent | null = null; vaultFilterComponent: VaultFilterComponent | null = null;
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
@@ -407,14 +413,14 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener {
this.messagingService.send("minimizeOnCopy"); this.messagingService.send("minimizeOnCopy");
} }
async viewCipher(cipher: CipherView) { async viewCipher(c: CipherViewLike) {
if (cipher.decryptionFailure) { if (CipherViewLikeUtils.decryptionFailure(c)) {
DecryptionFailureDialogComponent.open(this.dialogService, { DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId], cipherIds: [c.id as CipherId],
}); });
return; return;
} }
const cipher = await this.cipherService.getFullCipherView(c);
if (await this.shouldReprompt(cipher, "view")) { if (await this.shouldReprompt(cipher, "view")) {
return; 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[] = [ const menu: RendererMenuItem[] = [
{ {
label: this.i18nService.t("view"), label: this.i18nService.t("view"),

View File

@@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const filterFunction = createFilterFunction(filter); const filterFunction = createFilterFunction(filter);
if (await this.searchService.isSearchable(this.userId, searchText)) { if (await this.searchService.isSearchable(this.userId, searchText)) {
return await this.searchService.searchCiphers( return await this.searchService.searchCiphers<CipherView>(
this.userId, this.userId,
searchText, searchText,
[filterFunction], [filterFunction],
@@ -772,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
async onVaultItemsEvent(event: VaultItemEvent) { async onVaultItemsEvent(event: VaultItemEvent<CipherView>) {
this.processingEvent = true; this.processingEvent = true;
try { try {

View File

@@ -28,6 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { import {
DIALOG_DATA, DIALOG_DATA,
DialogRef, DialogRef,
@@ -91,7 +92,7 @@ export interface VaultItemDialogParams {
/** /**
* Function to restore a cipher from the trash. * Function to restore a cipher from the trash.
*/ */
restore?: (c: CipherView) => Promise<boolean>; restore?: (c: CipherViewLike) => Promise<boolean>;
} }
export const VaultItemDialogResult = { export const VaultItemDialogResult = {

View File

@@ -4,7 +4,7 @@
type="checkbox" type="checkbox"
bitCheckbox bitCheckbox
appStopProp appStopProp
[disabled]="disabled || cipher.decryptionFailure" [disabled]="disabled || decryptionFailure"
[checked]="checked" [checked]="checked"
(change)="$event ? this.checkedToggled.next() : null" (change)="$event ? this.checkedToggled.next() : null"
[attr.aria-label]="'vaultItemSelect' | i18n" [attr.aria-label]="'vaultItemSelect' | i18n"
@@ -30,7 +30,7 @@
> >
{{ cipher.name }} {{ cipher.name }}
</button> </button>
<ng-container *ngIf="cipher.hasAttachments"> <ng-container *ngIf="hasAttachments">
<i <i
class="bwi bwi-paperclip tw-ml-2 tw-leading-normal" class="bwi bwi-paperclip tw-ml-2 tw-leading-normal"
appStopProp appStopProp
@@ -50,7 +50,7 @@
</ng-container> </ng-container>
</div> </div>
<br /> <br />
<span class="tw-text-sm tw-text-muted" appStopProp>{{ cipher.subTitle }}</span> <span class="tw-text-sm tw-text-muted" appStopProp>{{ subtitle }}</span>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner" class="tw-hidden lg:tw-table-cell"> <td bitCell [ngClass]="RowHeightClass" *ngIf="showOwner" class="tw-hidden lg:tw-table-cell">
<app-org-badge <app-org-badge
@@ -76,7 +76,7 @@
</td> </td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button <button
*ngIf="cipher.decryptionFailure" *ngIf="decryptionFailure"
[disabled]="disabled || !canManageCollection" [disabled]="disabled || !canManageCollection"
[bitMenuTriggerFor]="corruptedCipherOptions" [bitMenuTriggerFor]="corruptedCipherOptions"
size="small" size="small"
@@ -89,12 +89,12 @@
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button"> <button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger"> <span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }} {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span> </span>
</button> </button>
</bit-menu> </bit-menu>
<button <button
*ngIf="!cipher.decryptionFailure" *ngIf="!decryptionFailure"
[disabled]="disabled || disableMenu" [disabled]="disabled || disableMenu"
[bitMenuTriggerFor]="cipherOptions" [bitMenuTriggerFor]="cipherOptions"
size="small" size="small"
@@ -105,11 +105,11 @@
></button> ></button>
<bit-menu #cipherOptions> <bit-menu #cipherOptions>
<ng-container *ngIf="isNotDeletedLoginCipher"> <ng-container *ngIf="isNotDeletedLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="cipher.login.username"> <button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }} {{ "copyUsername" | i18n }}
</button> </button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="cipher.login.password"> <button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }} {{ "copyPassword" | i18n }}
</button> </button>
@@ -119,9 +119,9 @@
</button> </button>
<a <a
bitMenuItem bitMenuItem
*ngIf="cipher.login.canLaunch" *ngIf="canLaunch"
type="button" type="button"
[href]="cipher.login.launchUri" [href]="launchUri"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -151,19 +151,14 @@
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }} {{ "eventLogs" | i18n }}
</button> </button>
<button <button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
bitMenuItem
(click)="restore()"
type="button"
*ngIf="cipher.isDeleted && canRestoreCipher"
>
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }} {{ "restore" | i18n }}
</button> </button>
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button"> <button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger"> <span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }} {{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span> </span>
</button> </button>
</bit-menu> </bit-menu>

View File

@@ -6,7 +6,10 @@ import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { import {
convertToPermission, convertToPermission,
@@ -20,11 +23,11 @@ import { RowHeightClass } from "./vault-items.component";
templateUrl: "vault-cipher-row.component.html", templateUrl: "vault-cipher-row.component.html",
standalone: false, standalone: false,
}) })
export class VaultCipherRowComponent implements OnInit { export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() cipher: CipherView; @Input() cipher: C;
@Input() showOwner: boolean; @Input() showOwner: boolean;
@Input() showCollections: boolean; @Input() showCollections: boolean;
@Input() showGroups: boolean; @Input() showGroups: boolean;
@@ -46,7 +49,7 @@ export class VaultCipherRowComponent implements OnInit {
*/ */
@Input() canRestoreCipher: boolean; @Input() canRestoreCipher: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>(); @Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@Input() checked: boolean; @Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>(); @Output() checkedToggled = new EventEmitter<void>();
@@ -74,33 +77,63 @@ export class VaultCipherRowComponent implements OnInit {
} }
protected get clickAction() { protected get clickAction() {
if (this.cipher.decryptionFailure) { if (this.decryptionFailure) {
return "showFailedToDecrypt"; return "showFailedToDecrypt";
} }
return "view"; return "view";
} }
protected get showTotpCopyButton() { protected get showTotpCopyButton() {
return ( const login = CipherViewLikeUtils.getLogin(this.cipher);
(this.cipher.login?.hasTotp ?? false) &&
(this.cipher.organizationUseTotp || this.showPremiumFeatures) const hasTotp = login?.totp ?? false;
);
return hasTotp && (this.cipher.organizationUseTotp || this.showPremiumFeatures);
} }
protected get showFixOldAttachments() { protected get showFixOldAttachments() {
return this.cipher.hasOldAttachments && this.cipher.organizationId == null; return this.cipher.hasOldAttachments && this.cipher.organizationId == null;
} }
protected get hasAttachments() {
return CipherViewLikeUtils.hasAttachments(this.cipher);
}
protected get showAttachments() { 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() { 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() { protected get showClone() {
return this.cloneable && !this.cipher.isDeleted; return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher);
} }
protected get showEventLogs() { protected get showEventLogs() {
@@ -108,7 +141,18 @@ export class VaultCipherRowComponent implements OnInit {
} }
protected get isNotDeletedLoginCipher() { 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() { protected get permissionText() {
@@ -154,7 +198,7 @@ export class VaultCipherRowComponent implements OnInit {
} }
protected get showLaunchUri(): boolean { protected get showLaunchUri(): boolean {
return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch; return this.isNotDeletedLoginCipher && this.canLaunch;
} }
protected get disableMenu() { protected get disableMenu() {
@@ -166,7 +210,7 @@ export class VaultCipherRowComponent implements OnInit {
this.showAttachments || this.showAttachments ||
this.showClone || this.showClone ||
this.canEditCipher || this.canEditCipher ||
(this.cipher.isDeleted && this.canRestoreCipher) (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher)
); );
} }

View File

@@ -5,6 +5,7 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CollectionAdminView, Unassigned, CollectionView } from "@bitwarden/admin-console/common"; import { CollectionAdminView, Unassigned, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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"; import { GroupView } from "../../../admin-console/organizations/core";
@@ -20,7 +21,7 @@ import { RowHeightClass } from "./vault-items.component";
templateUrl: "vault-collection-row.component.html", templateUrl: "vault-collection-row.component.html",
standalone: false, standalone: false,
}) })
export class VaultCollectionRowComponent { export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
protected Unassigned = "unassigned"; protected Unassigned = "unassigned";
@@ -36,7 +37,7 @@ export class VaultCollectionRowComponent {
@Input() groups: GroupView[]; @Input() groups: GroupView[];
@Input() showPermissionsColumn: boolean; @Input() showPermissionsColumn: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>(); @Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@Input() checked: boolean; @Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>(); @Output() checkedToggled = new EventEmitter<void>();

View File

@@ -1,17 +1,17 @@
import { CollectionView } from "@bitwarden/admin-console/common"; 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"; import { VaultItem } from "./vault-item";
export type VaultItemEvent = export type VaultItemEvent<C extends CipherViewLike> =
| { type: "viewAttachments"; item: CipherView } | { type: "viewAttachments"; item: C }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean } | { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean }
| { type: "viewEvents"; item: CipherView } | { type: "viewEvents"; item: C }
| { type: "editCollection"; item: CollectionView; readonly: boolean } | { type: "editCollection"; item: CollectionView; readonly: boolean }
| { type: "clone"; item: CipherView } | { type: "clone"; item: C }
| { type: "restore"; items: CipherView[] } | { type: "restore"; items: C[] }
| { type: "delete"; items: VaultItem[] } | { type: "delete"; items: VaultItem<C>[] }
| { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } | { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: CipherView[] } | { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: CipherView[] }; | { type: "assignToCollections"; items: C[] };

View File

@@ -1,7 +1,7 @@
import { CollectionView } from "@bitwarden/admin-console/common"; 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<C extends CipherViewLike> {
collection?: CollectionView; collection?: CollectionView;
cipher?: CipherView; cipher?: C;
} }

View File

@@ -6,8 +6,11 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { 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 { SortDirection, TableDataSource } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
@@ -32,7 +35,7 @@ type ItemPermission = CollectionPermission | "NoAccess";
templateUrl: "vault-items.component.html", templateUrl: "vault-items.component.html",
standalone: false, standalone: false,
}) })
export class VaultItemsComponent { export class VaultItemsComponent<C extends CipherViewLike> {
protected RowHeight = RowHeight; protected RowHeight = RowHeight;
@Input() disabled: boolean; @Input() disabled: boolean;
@@ -56,11 +59,11 @@ export class VaultItemsComponent {
@Input() addAccessToggle: boolean; @Input() addAccessToggle: boolean;
@Input() activeCollection: CollectionView | undefined; @Input() activeCollection: CollectionView | undefined;
private _ciphers?: CipherView[] = []; private _ciphers?: C[] = [];
@Input() get ciphers(): CipherView[] { @Input() get ciphers(): C[] {
return this._ciphers; return this._ciphers;
} }
set ciphers(value: CipherView[] | undefined) { set ciphers(value: C[] | undefined) {
this._ciphers = value ?? []; this._ciphers = value ?? [];
this.refreshItems(); this.refreshItems();
} }
@@ -74,11 +77,11 @@ export class VaultItemsComponent {
this.refreshItems(); this.refreshItems();
} }
@Output() onEvent = new EventEmitter<VaultItemEvent>(); @Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
protected editableItems: VaultItem[] = []; protected editableItems: VaultItem<C>[] = [];
protected dataSource = new TableDataSource<VaultItem>(); protected dataSource = new TableDataSource<VaultItem<C>>();
protected selection = new SelectionModel<VaultItem>(true, [], true); protected selection = new SelectionModel<VaultItem<C>>(true, [], true);
protected canDeleteSelected$: Observable<boolean>; protected canDeleteSelected$: Observable<boolean>;
protected canRestoreSelected$: Observable<boolean>; protected canRestoreSelected$: Observable<boolean>;
protected disableMenu$: Observable<boolean>; protected disableMenu$: Observable<boolean>;
@@ -233,7 +236,7 @@ export class VaultItemsComponent {
: this.selection.select(...this.editableItems.slice(0, MaxSelectionCount)); : this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
} }
protected event(event: VaultItemEvent) { protected event(event: VaultItemEvent<C>) {
this.onEvent.emit(event); this.onEvent.emit(event);
} }
@@ -263,7 +266,7 @@ export class VaultItemsComponent {
} }
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead // TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem) { protected canClone(vaultItem: VaultItem<C>) {
if (vaultItem.cipher.organizationId == null) { if (vaultItem.cipher.organizationId == null) {
return true; return true;
} }
@@ -287,7 +290,7 @@ export class VaultItemsComponent {
return false; return false;
} }
protected canEditCipher(cipher: CipherView) { protected canEditCipher(cipher: C) {
if (cipher.organizationId == null) { if (cipher.organizationId == null) {
return true; return true;
} }
@@ -296,17 +299,17 @@ export class VaultItemsComponent {
return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; 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 organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
const editableCollections = this.allCollections.filter((c) => !c.readOnly); const editableCollections = this.allCollections.filter((c) => !c.readOnly);
return ( return (
(organization?.canEditAllCiphers && this.viewingOrgVault) || (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 the cipher is not part of an organization (personal item), user can manage it
if (cipher.organizationId == null) { if (cipher.organizationId == null) {
return true; return true;
@@ -338,9 +341,11 @@ export class VaultItemsComponent {
} }
private refreshItems() { private refreshItems() {
const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); const collections: VaultItem<C>[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); const ciphers: VaultItem<C>[] = this.ciphers.map((cipher) => ({
const items: VaultItem[] = [].concat(collections).concat(ciphers); cipher,
}));
const items: VaultItem<C>[] = [].concat(collections).concat(ciphers);
// All ciphers are selectable, collections only if they can be edited or deleted // All ciphers are selectable, collections only if they can be edited or deleted
this.editableItems = items.filter( this.editableItems = items.filter(
@@ -419,7 +424,7 @@ export class VaultItemsComponent {
/** /**
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. * 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<C>, b: VaultItem<C>, direction: SortDirection) => {
// Collections before ciphers // Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction); const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) { if (collectionCompare !== 0) {
@@ -432,7 +437,7 @@ export class VaultItemsComponent {
/** /**
* Sorts VaultItems based on group names * Sorts VaultItems based on group names
*/ */
protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { protected sortByGroups = (a: VaultItem<C>, b: VaultItem<C>, direction: SortDirection) => {
if ( if (
!(a.collection instanceof CollectionAdminView) && !(a.collection instanceof CollectionAdminView) &&
!(b.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. * Sorts VaultItems based on their permissions, with higher permissions taking precedence.
* If permissions are equal, it falls back to sorting by name. * If permissions are equal, it falls back to sorting by name.
*/ */
protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { protected sortByPermissions = (a: VaultItem<C>, b: VaultItem<C>, direction: SortDirection) => {
const getPermissionPriority = (item: VaultItem): number => { const getPermissionPriority = (item: VaultItem<C>): number => {
const permission = item.collection const permission = item.collection
? this.getCollectionPermission(item.collection) ? this.getCollectionPermission(item.collection)
: this.getCipherPermission(item.cipher); : this.getCipherPermission(item.cipher);
@@ -508,8 +513,8 @@ export class VaultItemsComponent {
return this.compareNames(a, b); return this.compareNames(a, b);
}; };
private compareNames(a: VaultItem, b: VaultItem): number { private compareNames(a: VaultItem<C>, b: VaultItem<C>): number {
const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; const getName = (item: VaultItem<C>) => item.collection?.name || item.cipher?.name;
return getName(a)?.localeCompare(getName(b)) ?? -1; return getName(a)?.localeCompare(getName(b)) ?? -1;
} }
@@ -517,7 +522,11 @@ export class VaultItemsComponent {
* Sorts VaultItems by prioritizing collections over ciphers. * Sorts VaultItems by prioritizing collections over ciphers.
* Collections are always placed before ciphers, regardless of the sorting direction. * Collections are always placed before ciphers, regardless of the sorting direction.
*/ */
private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { private prioritizeCollections(
a: VaultItem<C>,
b: VaultItem<C>,
direction: SortDirection,
): number {
if (a.collection && !b.collection) { if (a.collection && !b.collection) {
return direction === "asc" ? -1 : 1; return direction === "asc" ? -1 : 1;
} }
@@ -561,7 +570,7 @@ export class VaultItemsComponent {
return "NoAccess"; return "NoAccess";
} }
private getCipherPermission(cipher: CipherView): ItemPermission { private getCipherPermission(cipher: C): ItemPermission {
if (!cipher.organizationId || cipher.collectionIds.length === 0) { if (!cipher.organizationId || cipher.collectionIds.length === 0) {
return CollectionPermission.Manage; return CollectionPermission.Manage;
} }

View File

@@ -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 { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.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 { LayoutComponent } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
@@ -158,7 +159,7 @@ export default {
argTypes: { onEvent: { action: "onEvent" } }, argTypes: { onEvent: { action: "onEvent" } },
} as Meta; } as Meta;
type Story = StoryObj<VaultItemsComponent>; type Story = StoryObj<VaultItemsComponent<CipherViewLike>>;
export const Individual: Story = { export const Individual: Story = {
args: { args: {

View File

@@ -85,7 +85,7 @@ describe("vault filter service", () => {
policyService.policyAppliesToUser$ policyService.policyAppliesToUser$
.calledWith(PolicyType.SingleOrg, mockUserId) .calledWith(PolicyType.SingleOrg, mockUserId)
.mockReturnValue(singleOrgPolicy); .mockReturnValue(singleOrgPolicy);
cipherService.cipherViews$.mockReturnValue(cipherViews); cipherService.cipherListViews$.mockReturnValue(cipherViews);
vaultFilterService = new VaultFilterService( vaultFilterService = new VaultFilterService(
organizationService, organizationService,

View File

@@ -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 { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { CipherListView } from "@bitwarden/sdk-internal";
import { import {
CipherTypeFilter, CipherTypeFilter,
@@ -85,7 +86,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
switchMap((userId) => switchMap((userId) =>
combineLatest([ combineLatest([
this.folderService.folderViews$(userId), this.folderService.folderViews$(userId),
this.cipherService.cipherViews$(userId), this.cipherService.cipherListViews$(userId),
this._organizationFilter, this._organizationFilter,
]), ]),
), ),
@@ -280,7 +281,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected async filterFolders( protected async filterFolders(
storedFolders: FolderView[], storedFolders: FolderView[],
ciphers: CipherView[], ciphers: CipherView[] | CipherListView[],
org?: Organization, org?: Organization,
): Promise<FolderView[]> { ): Promise<FolderView[]> {
// If no org or "My Vault" is selected, show all folders // If no org or "My Vault" is selected, show all folders

View File

@@ -221,7 +221,7 @@ function createCipher(options: Partial<CipherView> = {}) {
cipher.favorite = options.favorite ?? false; cipher.favorite = options.favorite ?? false;
cipher.deletedDate = options.deletedDate; cipher.deletedDate = options.deletedDate;
cipher.type = options.type; cipher.type = options.type ?? CipherType.Login;
cipher.folderId = options.folderId; cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds; cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId; cipher.organizationId = options.organizationId;

View File

@@ -1,40 +1,46 @@
import { Unassigned } from "@bitwarden/admin-console/common"; import { Unassigned } from "@bitwarden/admin-console/common";
import { CipherType } from "@bitwarden/common/vault/enums"; 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"; 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 { export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction {
return (cipher) => { return (cipher) => {
const type = CipherViewLikeUtils.getType(cipher);
const isDeleted = CipherViewLikeUtils.isDeleted(cipher);
if (filter.type === "favorites" && !cipher.favorite) { if (filter.type === "favorites" && !cipher.favorite) {
return false; return false;
} }
if (filter.type === "card" && cipher.type !== CipherType.Card) { if (filter.type === "card" && type !== CipherType.Card) {
return false; return false;
} }
if (filter.type === "identity" && cipher.type !== CipherType.Identity) { if (filter.type === "identity" && type !== CipherType.Identity) {
return false; return false;
} }
if (filter.type === "login" && cipher.type !== CipherType.Login) { if (filter.type === "login" && type !== CipherType.Login) {
return false; return false;
} }
if (filter.type === "note" && cipher.type !== CipherType.SecureNote) { if (filter.type === "note" && type !== CipherType.SecureNote) {
return false; return false;
} }
if (filter.type === "sshKey" && cipher.type !== CipherType.SshKey) { if (filter.type === "sshKey" && type !== CipherType.SshKey) {
return false; return false;
} }
if (filter.type === "trash" && !cipher.isDeleted) { if (filter.type === "trash" && !isDeleted) {
return false; return false;
} }
// Hide trash unless explicitly selected // Hide trash unless explicitly selected
if (filter.type !== "trash" && cipher.isDeleted) { if (filter.type !== "trash" && isDeleted) {
return false; return false;
} }
// No folder // No folder
if (filter.folderId === Unassigned && cipher.folderId !== null) { if (filter.folderId === Unassigned && cipher.folderId != null) {
return false; return false;
} }
// Folder // Folder

View File

@@ -24,7 +24,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; 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 { LinkModule } from "@bitwarden/components";
import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module"; import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module";
@@ -44,7 +44,7 @@ import { VaultOnboardingService, VaultOnboardingTasks } from "./services/vault-o
templateUrl: "vault-onboarding.component.html", templateUrl: "vault-onboarding.component.html",
}) })
export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy {
@Input() ciphers: CipherView[]; @Input() ciphers: CipherViewLike[];
@Input() orgs: Organization[]; @Input() orgs: Organization[];
@Output() onAddCipher = new EventEmitter<CipherType>(); @Output() onAddCipher = new EventEmitter<CipherType>();

View File

@@ -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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; 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 { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import { import {
AddEditFolderDialogComponent, AddEditFolderDialogComponent,
AddEditFolderDialogResult, AddEditFolderDialogResult,
@@ -149,7 +154,7 @@ const SearchTextDebounceInterval = 200;
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
], ],
}) })
export class VaultComponent implements OnInit, OnDestroy { export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
trashCleanupWarning: string = null; trashCleanupWarning: string = null;
@@ -165,7 +170,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected canAccessPremium: boolean; protected canAccessPremium: boolean;
protected allCollections: CollectionView[]; protected allCollections: CollectionView[];
protected allOrganizations: Organization[] = []; protected allOrganizations: Organization[] = [];
protected ciphers: CipherView[]; protected ciphers: C[];
protected collections: CollectionView[]; protected collections: CollectionView[];
protected isEmpty: boolean; protected isEmpty: boolean;
protected selectedCollection: TreeNode<CollectionView> | undefined; protected selectedCollection: TreeNode<CollectionView> | undefined;
@@ -350,11 +355,15 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); 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. * This observable filters the ciphers based on the active user ID and the restricted item types.
*/ */
const allowedCiphers$ = combineLatest([ const allowedCiphers$ = combineLatest([
this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)), _ciphers,
this.restrictedItemTypesService.restricted$, this.restrictedItemTypesService.restricted$,
]).pipe( ]).pipe(
map(([ciphers, restrictedTypes]) => map(([ciphers, restrictedTypes]) =>
@@ -374,15 +383,15 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers = [...failedCiphers, ...ciphers]; const allCiphers = [...failedCiphers, ...ciphers];
if (await this.searchService.isSearchable(activeUserId, searchText)) { if (await this.searchService.isSearchable(activeUserId, searchText)) {
return await this.searchService.searchCiphers( return await this.searchService.searchCiphers<C>(
activeUserId, activeUserId,
searchText, searchText,
[filterFunction], [filterFunction],
allCiphers, allCiphers as C[],
); );
} }
return allCiphers.filter(filterFunction); return ciphers.filter(filterFunction) as C[];
}), }),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
@@ -566,7 +575,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.vaultFilterService.clearOrganizationFilter(); this.vaultFilterService.clearOrganizationFilter();
} }
async onVaultItemsEvent(event: VaultItemEvent) { async onVaultItemsEvent(event: VaultItemEvent<C>) {
this.processingEvent = true; this.processingEvent = true;
try { try {
switch (event.type) { switch (event.type) {
@@ -654,7 +663,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* @param cipher * @param cipher
* @returns * @returns
*/ */
async editCipherAttachments(cipher: CipherView) { async editCipherAttachments(cipher: C) {
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
await this.go({ cipherId: null, itemId: null }); await this.go({ cipherId: null, itemId: null });
return; return;
@@ -761,7 +770,7 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.openVaultItemDialog("form", cipherFormConfig); await this.openVaultItemDialog("form", cipherFormConfig);
} }
async editCipher(cipher: CipherView, cloneMode?: boolean) { async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) {
return this.editCipherId(cipher?.id, cloneMode); 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))) { if (!(await this.repromptCipher(ciphers))) {
return; 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, { const dialog = AssignCollectionsWebComponent.open(this.dialogService, {
data: { data: {
ciphers, ciphers: ciphersToAssign,
organizationId: orgId as OrganizationId, organizationId: orgId as OrganizationId,
availableCollections, availableCollections,
activeCollection: this.activeFilter?.selectedCollectionNode?.node, activeCollection: this.activeFilter?.selectedCollectionNode?.node,
@@ -970,8 +998,8 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async cloneCipher(cipher: CipherView) { async cloneCipher(cipher: CipherView | CipherListView) {
if (cipher.login?.hasFido2Credentials) { if (CipherViewLikeUtils.hasFido2Credentials(cipher)) {
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" }, title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" }, content: { key: "passkeyNotCopiedAlert" },
@@ -986,8 +1014,8 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCipher(cipher, true); await this.editCipher(cipher, true);
} }
restore = async (c: CipherView): Promise<boolean> => { restore = async (c: C): Promise<boolean> => {
if (!c.isDeleted) { if (!CipherViewLikeUtils.isDeleted(c)) {
return; 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)) { if (ciphers.some((c) => !c.edit)) {
this.showMissingPermissionsError(); this.showMissingPermissionsError();
return; return;
@@ -1044,8 +1072,8 @@ export class VaultComponent implements OnInit, OnDestroy {
this.refresh(); this.refresh();
} }
private async handleDeleteEvent(items: VaultItem[]) { private async handleDeleteEvent(items: VaultItem<C>[]) {
const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher); 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); const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection);
if (ciphers.length === 1 && collections.length === 0) { if (ciphers.length === 1 && collections.length === 0) {
await this.deleteCipher(ciphers[0]); await this.deleteCipher(ciphers[0]);
@@ -1062,7 +1090,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async deleteCipher(c: CipherView): Promise<boolean> { async deleteCipher(c: C): Promise<boolean> {
if (!(await this.repromptCipher([c]))) { if (!(await this.repromptCipher([c]))) {
return; return;
} }
@@ -1072,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestroy {
return; return;
} }
const permanent = c.isDeleted; const permanent = CipherViewLikeUtils.isDeleted(c);
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" },
@@ -1099,11 +1127,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async bulkDelete( async bulkDelete(ciphers: C[], collections: CollectionView[], organizations: Organization[]) {
ciphers: CipherView[],
collections: CollectionView[],
organizations: Organization[],
) {
if (!(await this.repromptCipher(ciphers))) { if (!(await this.repromptCipher(ciphers))) {
return; return;
} }
@@ -1142,7 +1166,7 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async bulkMove(ciphers: CipherView[]) { async bulkMove(ciphers: C[]) {
if (!(await this.repromptCipher(ciphers))) { if (!(await this.repromptCipher(ciphers))) {
return; 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 aType;
let value; let value;
let typeI18nKey; let typeI18nKey;
const login = CipherViewLikeUtils.getLogin(cipher);
if (!login) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unexpectedError"),
});
}
if (field === "username") { if (field === "username") {
aType = "Username"; aType = "Username";
value = cipher.login.username; value = login.username;
typeI18nKey = "username"; typeI18nKey = "username";
} else if (field === "password") { } else if (field === "password") {
aType = "Password"; aType = "Password";
value = cipher.login.password; value = await this.getPasswordFromCipherViewLike(cipher);
typeI18nKey = "password"; typeI18nKey = "password";
} else if (field === "totp") { } else if (field === "totp") {
aType = "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; value = totpResponse.code;
typeI18nKey = "verificationCodeTotp"; typeI18nKey = "verificationCodeTotp";
} else { } else {
@@ -1228,7 +1262,7 @@ export class VaultComponent implements OnInit, OnDestroy {
: this.cipherService.softDeleteWithServer(id, userId); : this.cipherService.softDeleteWithServer(id, userId);
} }
protected async repromptCipher(ciphers: CipherView[]) { protected async repromptCipher(ciphers: C[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
@@ -1264,6 +1298,21 @@ export class VaultComponent implements OnInit, OnDestroy {
message: this.i18nService.t("missingPermissions"), 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<string | undefined> {
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;
}
} }
/** /**

View File

@@ -864,6 +864,23 @@
"copyName": { "copyName": {
"message": "Copy name" "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": { "me": {
"message": "Me" "message": "Me"
}, },

View File

@@ -13,7 +13,7 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; 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({ @Component({
selector: "app-vault-icon", selector: "app-vault-icon",
@@ -25,7 +25,7 @@ export class IconComponent {
/** /**
* The cipher to display the icon for. * The cipher to display the icon for.
*/ */
cipher = input.required<CipherView>(); cipher = input.required<CipherViewLike>();
imageLoaded = signal(false); imageLoaded = signal(false);

View File

@@ -21,20 +21,23 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; 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() @Directive()
export class VaultItemsComponent implements OnInit, OnDestroy { export class VaultItemsComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@Input() activeCipherId: string = null; @Input() activeCipherId: string = null;
@Output() onCipherClicked = new EventEmitter<CipherView>(); @Output() onCipherClicked = new EventEmitter<C>();
@Output() onCipherRightClicked = new EventEmitter<CipherView>(); @Output() onCipherRightClicked = new EventEmitter<C>();
@Output() onAddCipher = new EventEmitter<CipherType | undefined>(); @Output() onAddCipher = new EventEmitter<CipherType | undefined>();
@Output() onAddCipherOptions = new EventEmitter(); @Output() onAddCipherOptions = new EventEmitter();
loaded = false; loaded = false;
ciphers: CipherView[] = []; ciphers: C[] = [];
deleted = false; deleted = false;
organization: Organization; organization: Organization;
CipherType = CipherType; CipherType = CipherType;
@@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected searchPending = false; protected searchPending = false;
/** Construct filters as an observable so it can be appended to the cipher stream. */ /** 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<void>(); private destroy$ = new Subject<void>();
private isSearchable: boolean = false; private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject<string>(""); private _searchText$ = new BehaviorSubject<string>("");
@@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
return this._filter$.value; return this._filter$.value;
} }
set filter(value: (cipher: CipherView) => boolean | null) { set filter(value: (cipher: C) => boolean | null) {
this._filter$.next(value); this._filter$.next(value);
} }
@@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this.destroy$.complete(); 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; this.deleted = deleted ?? false;
await this.applyFilter(filter); await this.applyFilter(filter);
this.loaded = true; this.loaded = true;
} }
async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) { async reload(filter: (cipher: C) => boolean = null, deleted = false) {
this.loaded = false; this.loaded = false;
await this.load(filter, deleted); await this.load(filter, deleted);
} }
@@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
await this.reload(this.filter, this.deleted); await this.reload(this.filter, this.deleted);
} }
async applyFilter(filter: (cipher: CipherView) => boolean = null) { async applyFilter(filter: (cipher: C) => boolean = null) {
this.filter = filter; this.filter = filter;
} }
selectCipher(cipher: CipherView) { selectCipher(cipher: C) {
this.onCipherClicked.emit(cipher); this.onCipherClicked.emit(cipher);
} }
rightClickCipher(cipher: CipherView) { rightClickCipher(cipher: C) {
this.onCipherRightClicked.emit(cipher); this.onCipherRightClicked.emit(cipher);
} }
@@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
return !this.searchPending && this.isSearchable; 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 * Creates stream of dependencies that results in the list of ciphers to display
@@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
.pipe( .pipe(
switchMap((userId) => switchMap((userId) =>
combineLatest([ combineLatest([
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId), this.cipherService.failedToDecryptCiphers$(userId),
this._searchText$, this._searchText$,
this._filter$, this._filter$,
@@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
]), ]),
), ),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => { switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
let allCiphers = indexedCiphers ?? []; let allCiphers = (indexedCiphers ?? []) as C[];
const _failedCiphers = failedCiphers ?? []; const _failedCiphers = failedCiphers ?? [];
allCiphers = [..._failedCiphers, ...allCiphers]; allCiphers = [..._failedCiphers, ...allCiphers] as C[];
const restrictedTypeFilter = (cipher: CipherView) => const restrictedTypeFilter = (cipher: CipherViewLike) =>
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted); !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
return this.searchService.searchCiphers( return this.searchService.searchCiphers(

View File

@@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> { nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([ return combineLatest([
this.getNudgeStatus$(nudgeType, userId), this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId), this.cipherService.cipherListViews$(userId),
this.organizationService.organizations$(userId), this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$, this.collectionService.decryptedCollections$,
]).pipe( ]).pipe(

View File

@@ -1,11 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CipherType } from "@bitwarden/common/vault/enums"; 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"; import { CipherStatus } from "./cipher-status.model";
export type VaultFilterFunction = (cipher: CipherView) => boolean; export type VaultFilterFunction = (cipher: CipherViewLike) => boolean;
export class VaultFilter { export class VaultFilter {
cipherType?: CipherType; cipherType?: CipherType;
@@ -44,10 +47,10 @@ export class VaultFilter {
cipherPassesFilter = cipher.favorite; cipherPassesFilter = cipher.favorite;
} }
if (this.status === "trash" && cipherPassesFilter) { if (this.status === "trash" && cipherPassesFilter) {
cipherPassesFilter = cipher.isDeleted; cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
} }
if (this.cipherType != null && cipherPassesFilter) { if (this.cipherType != null && cipherPassesFilter) {
cipherPassesFilter = cipher.type === this.cipherType; cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
} }
if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) { if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) {
cipherPassesFilter = cipher.folderId == null; cipherPassesFilter = cipher.folderId == null;
@@ -68,7 +71,7 @@ export class VaultFilter {
cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId; cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId;
} }
if (this.myVaultOnly && cipherPassesFilter) { if (this.myVaultOnly && cipherPassesFilter) {
cipherPassesFilter = cipher.organizationId === null; cipherPassesFilter = cipher.organizationId == null;
} }
return cipherPassesFilter; return cipherPassesFilter;
}; };

View File

@@ -53,6 +53,7 @@ export enum FeatureFlag {
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
CipherKeyEncryption = "cipher-key-encryption", CipherKeyEncryption = "cipher-key-encryption",
EndUserNotifications = "pm-10609-end-user-notifications", EndUserNotifications = "pm-10609-end-user-notifications",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.EndUserNotifications]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM19315EndUserActivationMvp]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE,
/* Auth */ /* Auth */

View File

@@ -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. // 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 // eslint-disable-next-line no-restricted-imports
import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { UserKeyRotationDataProvider } from "@bitwarden/key-management";
import { CipherListView } from "@bitwarden/sdk-internal";
import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { UriMatchStrategySetting } from "../../models/domain/domain-service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; 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 { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view"; import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export type EncryptionContext = { export type EncryptionContext = {
cipher: Cipher; cipher: Cipher;
@@ -29,6 +31,7 @@ export type EncryptionContext = {
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> { export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
abstract cipherViews$(userId: UserId): Observable<CipherView[]>; abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
abstract cipherListViews$(userId: UserId): Observable<CipherListView[] | CipherView[]>;
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>; abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>; abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>;
/** /**
@@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
includeOtherTypes?: CipherType[], includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting, defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>; ): Promise<CipherView[]>;
abstract filterCiphersForUrl( abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: CipherView[], ciphers: C[],
url: string, url: string,
includeOtherTypes?: CipherType[], includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting, defaultMatch?: UriMatchStrategySetting,
): Promise<CipherView[]>; ): Promise<C[]>;
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>; abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
/** /**
* Gets ciphers belonging to the specified organization that the user has explicit collection level access to. * 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<Ciphe
userId: UserId, userId: UserId,
admin: boolean, admin: boolean,
): Promise<CipherData>; ): Promise<CipherData>;
abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number; abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number; abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number; abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
abstract softDelete(id: string | string[], userId: UserId): Promise<any>; abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>; abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>; abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise<any>;
@@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response, response: Response,
userId: UserId, userId: UserId,
): Promise<Uint8Array | null>; ): Promise<Uint8Array | null>;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.
* When a `CipherView` instance is passed, it returns it as is.
*/
abstract getFullCipherView(c: CipherViewLike): Promise<CipherView>;
} }

View File

@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
import { SendView } from "../../tools/send/models/view/send.view"; import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid"; import { IndexedEntityId, UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view"; import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService { export abstract class SearchService {
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>; indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
@@ -16,12 +17,16 @@ export abstract class SearchService {
ciphersToIndex: CipherView[], ciphersToIndex: CipherView[],
indexedEntityGuid?: string, indexedEntityGuid?: string,
) => Promise<void>; ) => Promise<void>;
searchCiphers: ( searchCiphers: <C extends CipherViewLike>(
userId: UserId, userId: UserId,
query: string, query: string,
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[],
ciphers?: CipherView[], ciphers?: C[],
) => Promise<CipherView[]>; ) => Promise<C[]>;
searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; searchCiphersBasic: <C extends CipherViewLike>(
ciphers: C[],
query: string,
deleted?: boolean,
) => C[];
searchSends: (sends: SendView[], query: string) => SendView[]; searchSends: (sends: SendView[], query: string) => SendView[];
} }

View File

@@ -1,6 +1,6 @@
import { Utils } from "../../platform/misc/utils"; import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type"; 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 { export interface CipherIconDetails {
imageEnabled: boolean; imageEnabled: boolean;
@@ -14,7 +14,7 @@ export interface CipherIconDetails {
export function buildCipherIcon( export function buildCipherIcon(
iconsServerUrl: string | null, iconsServerUrl: string | null,
cipher: CipherView, cipher: CipherViewLike,
showFavicon: boolean, showFavicon: boolean,
): CipherIconDetails { ): CipherIconDetails {
let icon: string = "bwi-globe"; let icon: string = "bwi-globe";
@@ -36,12 +36,16 @@ export function buildCipherIcon(
showFavicon = false; 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: case CipherType.Login:
icon = "bwi-globe"; icon = "bwi-globe";
if (cipher.login.uri) { if (uri) {
let hostnameUri = cipher.login.uri; let hostnameUri = uri;
let isWebsite = false; let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) { if (hostnameUri.indexOf("androidapp://") === 0) {
@@ -84,8 +88,8 @@ export function buildCipherIcon(
break; break;
case CipherType.Card: case CipherType.Card:
icon = "bwi-credit-card"; icon = "bwi-credit-card";
if (showFavicon && cipher.card.brand in cardIcons) { if (showFavicon && card?.brand && card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`; icon = `credit-card-icon ${cardIcons[card.brand]}`;
} }
break; break;
case CipherType.Identity: case CipherType.Identity:

View File

@@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { CollectionId } from "@bitwarden/common/types/guid"; import { CollectionId } from "@bitwarden/common/types/guid";
import { getUserId } from "../../auth/services/account.service"; import { getUserId } from "../../auth/services/account.service";
import { Cipher } from "../models/domain/cipher"; import { CipherLike } from "../types/cipher-like";
import { CipherView } from "../models/view/cipher.view";
/**
* Represents either a cipher or a cipher view.
*/
type CipherLike = Cipher | CipherView;
/** /**
* Service for managing user cipher authorization. * 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;
}), }),
); );
} }

View File

@@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view"; import { FieldView } from "../models/view/field.view";
import { PasswordHistoryView } from "../models/view/password-history.view"; import { PasswordHistoryView } from "../models/view/password-history.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
import { import {
ADD_EDIT_CIPHER_INFO_KEY, ADD_EDIT_CIPHER_INFO_KEY,
@@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction {
return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {})); 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. * 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. * 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), filter((c) => c != null),
switchMap( switchMap(
async (ciphers) => async (ciphers) =>
await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch), await this.filterCiphersForUrl<CipherView>(
ciphers,
url,
includeOtherTypes,
defaultMatch,
),
), ),
), ),
); );
} }
async filterCiphersForUrl( async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: CipherView[], ciphers: C[],
url: string, url: string,
includeOtherTypes?: CipherType[], includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null, defaultMatch: UriMatchStrategySetting = null,
): Promise<CipherView[]> { ): Promise<C[]> {
if (url == null && includeOtherTypes == null) { if (url == null && includeOtherTypes == null) {
return []; return [];
} }
@@ -565,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction {
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
return ciphers.filter((cipher) => { 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; return false;
} }
if ( if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) {
Array.isArray(includeOtherTypes) &&
includeOtherTypes.includes(cipher.type) &&
!cipherIsLogin
) {
return true; return true;
} }
if (cipherIsLogin) { if (cipherIsLogin) {
return cipher.login.matchesUri(url, equivalentDomains, defaultMatch); return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
} }
return false; return false;
@@ -1173,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction {
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId); return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
} }
sortCiphersByLastUsed(a: CipherView, b: CipherView): number { sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number {
const aLastUsed = const aLastUsed =
a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null; a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null;
const bLastUsed = const bLastUsed =
@@ -1197,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction {
return 0; return 0;
} }
sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number { sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number {
const result = this.sortCiphersByLastUsed(a, b); const result = this.sortCiphersByLastUsed(a, b);
if (result !== 0) { if (result !== 0) {
return result; return result;
@@ -1206,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction {
return this.getLocaleSortingFunction()(a, b); return this.getLocaleSortingFunction()(a, b);
} }
getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number { getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number {
return (a, b) => { return (a, b) => {
let aName = a.name; let aName = a.name;
let bName = b.name; let bName = b.name;
@@ -1225,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction {
? this.i18nService.collator.compare(aName, bName) ? this.i18nService.collator.compare(aName, bName)
: aName.localeCompare(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; return result;
} }
if (a.login.username != null) { const aLogin = CipherViewLikeUtils.getLogin(a);
aName += a.login.username; const bLogin = CipherViewLikeUtils.getLogin(b);
if (aLogin.username != null) {
aName += aLogin.username;
} }
if (b.login.username != null) { if (bLogin.username != null) {
bName += b.login.username; bName += bLogin.username;
} }
return this.i18nService.collator return this.i18nService.collator
@@ -1902,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction {
return decryptedViews.sort(this.getLocaleSortingFunction()); return decryptedViews.sort(this.getLocaleSortingFunction());
} }
/** Fetches the full `CipherView` when a `CipherListView` is passed. */
async getFullCipherView(c: CipherViewLike): Promise<CipherView> {
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);
}
} }

View File

@@ -9,17 +9,15 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 = { export type RestrictedCipherType = {
cipherType: CipherType; cipherType: CipherType;
allowViewOrgIds: string[]; allowViewOrgIds: string[];
}; };
type CipherLike = Cipher | CipherView;
export class RestrictedItemTypesService { export class RestrictedItemTypesService {
/** /**
* Emits an array of RestrictedCipherType objects: * Emits an array of RestrictedCipherType objects:
@@ -94,7 +92,9 @@ export class RestrictedItemTypesService {
* - Otherwise → restricted * - Otherwise → restricted
*/ */
isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean { 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 cipher type is not restricted by any organization, allow it
if (!restriction) { if (!restriction) {

View File

@@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc
import { FieldType } from "../enums"; import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type"; import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view"; import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
export type SerializedLunrIndex = { export type SerializedLunrIndex = {
version: string; version: string;
@@ -197,13 +198,13 @@ export class SearchService implements SearchServiceAbstraction {
]); ]);
} }
async searchCiphers( async searchCiphers<C extends CipherViewLike>(
userId: UserId, userId: UserId,
query: string, query: string,
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null, filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: CipherView[], ciphers: C[],
): Promise<CipherView[]> { ): Promise<C[]> {
const results: CipherView[] = []; const results: C[] = [];
if (query != null) { if (query != null) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
} }
@@ -218,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction {
if (filter != null && Array.isArray(filter) && filter.length > 0) { if (filter != null && Array.isArray(filter) && filter.length > 0) {
ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c))); ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c)));
} else if (filter != null) { } 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))) { if (!(await this.isSearchable(userId, query))) {
@@ -238,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction {
return this.searchCiphersBasic(ciphers, query); return this.searchCiphersBasic(ciphers, query);
} }
const ciphersMap = new Map<string, CipherView>(); const ciphersMap = new Map<string, C>();
ciphers.forEach((c) => ciphersMap.set(c.id, c)); ciphers.forEach((c) => ciphersMap.set(c.id, c));
let searchResults: lunr.Index.Result[] = null; let searchResults: lunr.Index.Result[] = null;
@@ -272,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction {
return results; return results;
} }
searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) { searchCiphersBasic<C extends CipherViewLike>(ciphers: C[], query: string, deleted = false) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
return ciphers.filter((c) => { return ciphers.filter((c) => {
if (deleted !== c.isDeleted) { if (deleted !== CipherViewLikeUtils.isDeleted(c)) {
return false; return false;
} }
if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { 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)) { if (query.length >= 8 && c.id.startsWith(query)) {
return true; 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; return true;
} }
const login = CipherViewLikeUtils.getLogin(c);
if ( if (
c.login && login &&
c.login.hasUris && login.uris.length &&
c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
) { ) {
return true; return true;
} }

View File

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

View File

@@ -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<string>();
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);
});
});
});

View File

@@ -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<string>,
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<string, CopyableCipherFields> = {
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;
};

View File

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

View File

@@ -1,6 +1,14 @@
import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; 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 { 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 { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
@@ -27,10 +35,12 @@ export class CopyCipherFieldDirective implements OnChanges {
}) })
action!: Exclude<CopyAction, "hiddenField">; action!: Exclude<CopyAction, "hiddenField">;
@Input({ required: true }) cipher!: CipherView; @Input({ required: true }) cipher!: CipherViewLike;
constructor( constructor(
private copyCipherFieldService: CopyCipherFieldService, private copyCipherFieldService: CopyCipherFieldService,
private accountService: AccountService,
private cipherService: CipherService,
@Optional() private menuItemDirective?: MenuItemDirective, @Optional() private menuItemDirective?: MenuItemDirective,
@Optional() private iconButtonComponent?: BitIconButtonComponent, @Optional() private iconButtonComponent?: BitIconButtonComponent,
) {} ) {}
@@ -49,7 +59,7 @@ export class CopyCipherFieldDirective implements OnChanges {
@HostListener("click") @HostListener("click")
async copy() { async copy() {
const value = this.getValueToCopy(); const value = await this.getValueToCopy();
await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher); await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher);
} }
@@ -60,7 +70,7 @@ export class CopyCipherFieldDirective implements OnChanges {
private async updateDisabledState() { private async updateDisabledState() {
this.disabled = this.disabled =
!this.cipher || !this.cipher ||
!this.getValueToCopy() || !this.hasValueToCopy() ||
(this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher)))
? true ? true
: null; : 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) { switch (this.action) {
case "username": case "username":
return this.cipher.login?.username || this.cipher.identity?.username; return _cipher.login?.username || _cipher.identity?.username;
case "password": case "password":
return this.cipher.login?.password; return _cipher.login?.password;
case "totp": case "totp":
return this.cipher.login?.totp; return _cipher.login?.totp;
case "cardNumber": case "cardNumber":
return this.cipher.card?.number; return _cipher.card?.number;
case "securityCode": case "securityCode":
return this.cipher.card?.code; return _cipher.card?.code;
case "email": case "email":
return this.cipher.identity?.email; return _cipher.identity?.email;
case "phone": case "phone":
return this.cipher.identity?.phone; return _cipher.identity?.phone;
case "address": case "address":
return this.cipher.identity?.fullAddressForCopy; return _cipher.identity?.fullAddressForCopy;
case "secureNote": case "secureNote":
return this.cipher.notes; return _cipher.notes;
case "privateKey": case "privateKey":
return this.cipher.sshKey?.privateKey; return _cipher.sshKey?.privateKey;
case "publicKey": case "publicKey":
return this.cipher.sshKey?.publicKey; return _cipher.sshKey?.publicKey;
case "keyFingerprint": case "keyFingerprint":
return this.cipher.sshKey?.keyFingerprint; return _cipher.sshKey?.keyFingerprint;
default: default:
return null; return null;
} }

View File

@@ -8,7 +8,7 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
@@ -128,6 +128,7 @@ describe("CopyCipherFieldService", () => {
describe("totp", () => { describe("totp", () => {
beforeEach(() => { beforeEach(() => {
actionType = "totp"; actionType = "totp";
cipher.type = CipherType.Login;
cipher.login = new LoginView(); cipher.login = new LoginView();
cipher.login.totp = "secret-totp"; cipher.login.totp = "secret-totp";
cipher.reprompt = CipherRepromptType.None; cipher.reprompt = CipherRepromptType.None;

View File

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

View File

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