1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +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

@@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const filterFunction = createFilterFunction(filter);
if (await this.searchService.isSearchable(this.userId, searchText)) {
return await this.searchService.searchCiphers(
return await this.searchService.searchCiphers<CipherView>(
this.userId,
searchText,
[filterFunction],
@@ -772,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async onVaultItemsEvent(event: VaultItemEvent) {
async onVaultItemsEvent(event: VaultItemEvent<CipherView>) {
this.processingEvent = true;
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 { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
DIALOG_DATA,
DialogRef,
@@ -91,7 +92,7 @@ export interface VaultItemDialogParams {
/**
* Function to restore a cipher from the trash.
*/
restore?: (c: CipherView) => Promise<boolean>;
restore?: (c: CipherViewLike) => Promise<boolean>;
}
export const VaultItemDialogResult = {

View File

@@ -4,7 +4,7 @@
type="checkbox"
bitCheckbox
appStopProp
[disabled]="disabled || cipher.decryptionFailure"
[disabled]="disabled || decryptionFailure"
[checked]="checked"
(change)="$event ? this.checkedToggled.next() : null"
[attr.aria-label]="'vaultItemSelect' | i18n"
@@ -30,7 +30,7 @@
>
{{ cipher.name }}
</button>
<ng-container *ngIf="cipher.hasAttachments">
<ng-container *ngIf="hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-2 tw-leading-normal"
appStopProp
@@ -50,7 +50,7 @@
</ng-container>
</div>
<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 bitCell [ngClass]="RowHeightClass" *ngIf="showOwner" class="tw-hidden lg:tw-table-cell">
<app-org-badge
@@ -76,7 +76,7 @@
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
*ngIf="cipher.decryptionFailure"
*ngIf="decryptionFailure"
[disabled]="disabled || !canManageCollection"
[bitMenuTriggerFor]="corruptedCipherOptions"
size="small"
@@ -89,12 +89,12 @@
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>
<button
*ngIf="!cipher.decryptionFailure"
*ngIf="!decryptionFailure"
[disabled]="disabled || disableMenu"
[bitMenuTriggerFor]="cipherOptions"
size="small"
@@ -105,11 +105,11 @@
></button>
<bit-menu #cipherOptions>
<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>
{{ "copyUsername" | i18n }}
</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>
{{ "copyPassword" | i18n }}
</button>
@@ -119,9 +119,9 @@
</button>
<a
bitMenuItem
*ngIf="cipher.login.canLaunch"
*ngIf="canLaunch"
type="button"
[href]="cipher.login.launchUri"
[href]="launchUri"
target="_blank"
rel="noreferrer"
>
@@ -151,19 +151,14 @@
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button
bitMenuItem
(click)="restore()"
type="button"
*ngIf="cipher.isDeleted && canRestoreCipher"
>
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
convertToPermission,
@@ -20,11 +23,11 @@ import { RowHeightClass } from "./vault-items.component";
templateUrl: "vault-cipher-row.component.html",
standalone: false,
})
export class VaultCipherRowComponent implements OnInit {
export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
protected RowHeightClass = RowHeightClass;
@Input() disabled: boolean;
@Input() cipher: CipherView;
@Input() cipher: C;
@Input() showOwner: boolean;
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@@ -46,7 +49,7 @@ export class VaultCipherRowComponent implements OnInit {
*/
@Input() canRestoreCipher: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>();
@@ -74,33 +77,63 @@ export class VaultCipherRowComponent implements OnInit {
}
protected get clickAction() {
if (this.cipher.decryptionFailure) {
if (this.decryptionFailure) {
return "showFailedToDecrypt";
}
return "view";
}
protected get showTotpCopyButton() {
return (
(this.cipher.login?.hasTotp ?? false) &&
(this.cipher.organizationUseTotp || this.showPremiumFeatures)
);
const login = CipherViewLikeUtils.getLogin(this.cipher);
const hasTotp = login?.totp ?? false;
return hasTotp && (this.cipher.organizationUseTotp || this.showPremiumFeatures);
}
protected get showFixOldAttachments() {
return this.cipher.hasOldAttachments && this.cipher.organizationId == null;
}
protected get hasAttachments() {
return CipherViewLikeUtils.hasAttachments(this.cipher);
}
protected get showAttachments() {
return this.canEditCipher || this.cipher.attachments?.length > 0;
return this.canEditCipher || this.hasAttachments;
}
protected get canLaunch() {
return CipherViewLikeUtils.canLaunch(this.cipher);
}
protected get launchUri() {
return CipherViewLikeUtils.getLaunchUri(this.cipher);
}
protected get subtitle() {
return CipherViewLikeUtils.subtitle(this.cipher);
}
protected get isDeleted() {
return CipherViewLikeUtils.isDeleted(this.cipher);
}
protected get decryptionFailure() {
return CipherViewLikeUtils.decryptionFailure(this.cipher);
}
protected get showAssignToCollections() {
return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted;
return (
this.organizations?.length &&
this.canAssignCollections &&
!CipherViewLikeUtils.isDeleted(this.cipher)
);
}
protected get showClone() {
return this.cloneable && !this.cipher.isDeleted;
return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher);
}
protected get showEventLogs() {
@@ -108,7 +141,18 @@ export class VaultCipherRowComponent implements OnInit {
}
protected get isNotDeletedLoginCipher() {
return this.cipher.type === this.CipherType.Login && !this.cipher.isDeleted;
return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
!CipherViewLikeUtils.isDeleted(this.cipher)
);
}
protected get hasPasswordToCopy() {
return CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
}
protected get hasUsernameToCopy() {
return CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
}
protected get permissionText() {
@@ -154,7 +198,7 @@ export class VaultCipherRowComponent implements OnInit {
}
protected get showLaunchUri(): boolean {
return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch;
return this.isNotDeletedLoginCipher && this.canLaunch;
}
protected get disableMenu() {
@@ -166,7 +210,7 @@ export class VaultCipherRowComponent implements OnInit {
this.showAttachments ||
this.showClone ||
this.canEditCipher ||
(this.cipher.isDeleted && this.canRestoreCipher)
(CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher)
);
}

View File

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

View File

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

View File

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

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

View File

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

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 { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
CipherTypeFilter,
@@ -85,7 +86,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
switchMap((userId) =>
combineLatest([
this.folderService.folderViews$(userId),
this.cipherService.cipherViews$(userId),
this.cipherService.cipherListViews$(userId),
this._organizationFilter,
]),
),
@@ -280,7 +281,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
protected async filterFolders(
storedFolders: FolderView[],
ciphers: CipherView[],
ciphers: CipherView[] | CipherListView[],
org?: Organization,
): Promise<FolderView[]> {
// 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.deletedDate = options.deletedDate;
cipher.type = options.type;
cipher.type = options.type ?? CipherType.Login;
cipher.folderId = options.folderId;
cipher.collectionIds = options.collectionIds;
cipher.organizationId = options.organizationId;

View File

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

View File

@@ -24,7 +24,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { LinkModule } from "@bitwarden/components";
import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module";
@@ -44,7 +44,7 @@ import { VaultOnboardingService, VaultOnboardingTasks } from "./services/vault-o
templateUrl: "vault-onboarding.component.html",
})
export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy {
@Input() ciphers: CipherView[];
@Input() ciphers: CipherViewLike[];
@Input() orgs: Organization[];
@Output() onAddCipher = new EventEmitter<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 { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
@@ -149,7 +154,7 @@ const SearchTextDebounceInterval = 200;
DefaultCipherFormConfigService,
],
})
export class VaultComponent implements OnInit, OnDestroy {
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
trashCleanupWarning: string = null;
@@ -165,7 +170,7 @@ export class VaultComponent implements OnInit, OnDestroy {
protected canAccessPremium: boolean;
protected allCollections: CollectionView[];
protected allOrganizations: Organization[] = [];
protected ciphers: CipherView[];
protected ciphers: C[];
protected collections: CollectionView[];
protected isEmpty: boolean;
protected selectedCollection: TreeNode<CollectionView> | undefined;
@@ -350,11 +355,15 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const _ciphers = this.cipherService
.cipherListViews$(activeUserId)
.pipe(filter((c) => c !== null));
/**
* This observable filters the ciphers based on the active user ID and the restricted item types.
*/
const allowedCiphers$ = combineLatest([
this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)),
_ciphers,
this.restrictedItemTypesService.restricted$,
]).pipe(
map(([ciphers, restrictedTypes]) =>
@@ -374,15 +383,15 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers = [...failedCiphers, ...ciphers];
if (await this.searchService.isSearchable(activeUserId, searchText)) {
return await this.searchService.searchCiphers(
return await this.searchService.searchCiphers<C>(
activeUserId,
searchText,
[filterFunction],
allCiphers,
allCiphers as C[],
);
}
return allCiphers.filter(filterFunction);
return ciphers.filter(filterFunction) as C[];
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -566,7 +575,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.vaultFilterService.clearOrganizationFilter();
}
async onVaultItemsEvent(event: VaultItemEvent) {
async onVaultItemsEvent(event: VaultItemEvent<C>) {
this.processingEvent = true;
try {
switch (event.type) {
@@ -654,7 +663,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* @param cipher
* @returns
*/
async editCipherAttachments(cipher: CipherView) {
async editCipherAttachments(cipher: C) {
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
await this.go({ cipherId: null, itemId: null });
return;
@@ -761,7 +770,7 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.openVaultItemDialog("form", cipherFormConfig);
}
async editCipher(cipher: CipherView, cloneMode?: boolean) {
async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) {
return this.editCipherId(cipher?.id, cloneMode);
}
@@ -929,7 +938,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkAssignToCollections(ciphers: CipherView[]) {
async bulkAssignToCollections(ciphers: C[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
@@ -955,9 +964,28 @@ export class VaultComponent implements OnInit, OnDestroy {
);
}
let ciphersToAssign: CipherView[];
// Convert `CipherListView` to `CipherView` if necessary
if (ciphers.some(CipherViewLikeUtils.isCipherListView)) {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
ciphersToAssign = await firstValueFrom(
this.cipherService
.cipherViews$(userId)
.pipe(
map(
(cipherViews) =>
cipherViews.filter((c) => ciphers.some((cc) => cc.id === c.id)) as CipherView[],
),
),
);
} else {
ciphersToAssign = ciphers as CipherView[];
}
const dialog = AssignCollectionsWebComponent.open(this.dialogService, {
data: {
ciphers,
ciphers: ciphersToAssign,
organizationId: orgId as OrganizationId,
availableCollections,
activeCollection: this.activeFilter?.selectedCollectionNode?.node,
@@ -970,8 +998,8 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async cloneCipher(cipher: CipherView) {
if (cipher.login?.hasFido2Credentials) {
async cloneCipher(cipher: CipherView | CipherListView) {
if (CipherViewLikeUtils.hasFido2Credentials(cipher)) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
@@ -986,8 +1014,8 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCipher(cipher, true);
}
restore = async (c: CipherView): Promise<boolean> => {
if (!c.isDeleted) {
restore = async (c: C): Promise<boolean> => {
if (!CipherViewLikeUtils.isDeleted(c)) {
return;
}
@@ -1014,7 +1042,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
};
async bulkRestore(ciphers: CipherView[]) {
async bulkRestore(ciphers: C[]) {
if (ciphers.some((c) => !c.edit)) {
this.showMissingPermissionsError();
return;
@@ -1044,8 +1072,8 @@ export class VaultComponent implements OnInit, OnDestroy {
this.refresh();
}
private async handleDeleteEvent(items: VaultItem[]) {
const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher);
private async handleDeleteEvent(items: VaultItem<C>[]) {
const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher);
const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection);
if (ciphers.length === 1 && collections.length === 0) {
await this.deleteCipher(ciphers[0]);
@@ -1062,7 +1090,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async deleteCipher(c: CipherView): Promise<boolean> {
async deleteCipher(c: C): Promise<boolean> {
if (!(await this.repromptCipher([c]))) {
return;
}
@@ -1072,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
const permanent = c.isDeleted;
const permanent = CipherViewLikeUtils.isDeleted(c);
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" },
@@ -1099,11 +1127,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkDelete(
ciphers: CipherView[],
collections: CollectionView[],
organizations: Organization[],
) {
async bulkDelete(ciphers: C[], collections: CollectionView[], organizations: Organization[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
@@ -1142,7 +1166,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkMove(ciphers: CipherView[]) {
async bulkMove(ciphers: C[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
@@ -1167,22 +1191,32 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async copy(cipher: CipherView, field: "username" | "password" | "totp") {
async copy(cipher: C, field: "username" | "password" | "totp") {
let aType;
let value;
let typeI18nKey;
const login = CipherViewLikeUtils.getLogin(cipher);
if (!login) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("unexpectedError"),
});
}
if (field === "username") {
aType = "Username";
value = cipher.login.username;
value = login.username;
typeI18nKey = "username";
} else if (field === "password") {
aType = "Password";
value = cipher.login.password;
value = await this.getPasswordFromCipherViewLike(cipher);
typeI18nKey = "password";
} else if (field === "totp") {
aType = "TOTP";
const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp));
const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp));
value = totpResponse.code;
typeI18nKey = "verificationCodeTotp";
} else {
@@ -1228,7 +1262,7 @@ export class VaultComponent implements OnInit, OnDestroy {
: this.cipherService.softDeleteWithServer(id, userId);
}
protected async repromptCipher(ciphers: CipherView[]) {
protected async repromptCipher(ciphers: C[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);
return notProtected || (await this.passwordRepromptService.showPasswordPrompt());
@@ -1264,6 +1298,21 @@ export class VaultComponent implements OnInit, OnDestroy {
message: this.i18nService.t("missingPermissions"),
});
}
/**
* Returns the password for a `CipherViewLike` object.
* `CipherListView` does not contain the password, the full `CipherView` needs to be fetched.
*/
private async getPasswordFromCipherViewLike(cipher: C): Promise<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": {
"message": "Copy name"
},
"cardNumber": {
"message": "card number"
},
"copyFieldCipherName": {
"message": "Copy $FIELD$, $CIPHERNAME$",
"description": "Title for a button that copies a field value to the clipboard.",
"placeholders": {
"field": {
"content": "$1",
"example": "Username"
},
"ciphername": {
"content": "$2",
"example": "Login Item"
}
}
},
"me": {
"message": "Me"
},