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:
@@ -13,7 +13,7 @@ import {
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-icon",
|
||||
@@ -25,7 +25,7 @@ export class IconComponent {
|
||||
/**
|
||||
* The cipher to display the icon for.
|
||||
*/
|
||||
cipher = input.required<CipherView>();
|
||||
cipher = input.required<CipherViewLike>();
|
||||
|
||||
imageLoaded = signal(false);
|
||||
|
||||
|
||||
@@ -21,20 +21,23 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
@Directive()
|
||||
export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
export class VaultItemsComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
|
||||
@Input() activeCipherId: string = null;
|
||||
@Output() onCipherClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
|
||||
@Output() onCipherClicked = new EventEmitter<C>();
|
||||
@Output() onCipherRightClicked = new EventEmitter<C>();
|
||||
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
|
||||
@Output() onAddCipherOptions = new EventEmitter();
|
||||
|
||||
loaded = false;
|
||||
ciphers: CipherView[] = [];
|
||||
ciphers: C[] = [];
|
||||
deleted = false;
|
||||
organization: Organization;
|
||||
CipherType = CipherType;
|
||||
@@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
protected searchPending = false;
|
||||
|
||||
/** Construct filters as an observable so it can be appended to the cipher stream. */
|
||||
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
|
||||
private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null);
|
||||
private destroy$ = new Subject<void>();
|
||||
private isSearchable: boolean = false;
|
||||
private _searchText$ = new BehaviorSubject<string>("");
|
||||
@@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
return this._filter$.value;
|
||||
}
|
||||
|
||||
set filter(value: (cipher: CipherView) => boolean | null) {
|
||||
set filter(value: (cipher: C) => boolean | null) {
|
||||
this._filter$.next(value);
|
||||
}
|
||||
|
||||
@@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||
async load(filter: (cipher: C) => boolean = null, deleted = false) {
|
||||
this.deleted = deleted ?? false;
|
||||
await this.applyFilter(filter);
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) {
|
||||
async reload(filter: (cipher: C) => boolean = null, deleted = false) {
|
||||
this.loaded = false;
|
||||
await this.load(filter, deleted);
|
||||
}
|
||||
@@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
await this.reload(this.filter, this.deleted);
|
||||
}
|
||||
|
||||
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
||||
async applyFilter(filter: (cipher: C) => boolean = null) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
selectCipher(cipher: CipherView) {
|
||||
selectCipher(cipher: C) {
|
||||
this.onCipherClicked.emit(cipher);
|
||||
}
|
||||
|
||||
rightClickCipher(cipher: CipherView) {
|
||||
rightClickCipher(cipher: C) {
|
||||
this.onCipherRightClicked.emit(cipher);
|
||||
}
|
||||
|
||||
@@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
return !this.searchPending && this.isSearchable;
|
||||
}
|
||||
|
||||
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
|
||||
protected deletedFilter: (cipher: C) => boolean = (c) =>
|
||||
CipherViewLikeUtils.isDeleted(c) === this.deleted;
|
||||
|
||||
/**
|
||||
* Creates stream of dependencies that results in the list of ciphers to display
|
||||
@@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||
this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||
this.cipherService.failedToDecryptCiphers$(userId),
|
||||
this._searchText$,
|
||||
this._filter$,
|
||||
@@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
||||
]),
|
||||
),
|
||||
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
|
||||
let allCiphers = indexedCiphers ?? [];
|
||||
let allCiphers = (indexedCiphers ?? []) as C[];
|
||||
const _failedCiphers = failedCiphers ?? [];
|
||||
|
||||
allCiphers = [..._failedCiphers, ...allCiphers];
|
||||
allCiphers = [..._failedCiphers, ...allCiphers] as C[];
|
||||
|
||||
const restrictedTypeFilter = (cipher: CipherView) =>
|
||||
const restrictedTypeFilter = (cipher: CipherViewLike) =>
|
||||
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
|
||||
|
||||
return this.searchService.searchCiphers(
|
||||
|
||||
@@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.cipherService.cipherListViews$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.collectionService.decryptedCollections$,
|
||||
]).pipe(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherStatus } from "./cipher-status.model";
|
||||
|
||||
export type VaultFilterFunction = (cipher: CipherView) => boolean;
|
||||
export type VaultFilterFunction = (cipher: CipherViewLike) => boolean;
|
||||
|
||||
export class VaultFilter {
|
||||
cipherType?: CipherType;
|
||||
@@ -44,10 +47,10 @@ export class VaultFilter {
|
||||
cipherPassesFilter = cipher.favorite;
|
||||
}
|
||||
if (this.status === "trash" && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.isDeleted;
|
||||
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.type === this.cipherType;
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
}
|
||||
if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.folderId == null;
|
||||
@@ -68,7 +71,7 @@ export class VaultFilter {
|
||||
cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId;
|
||||
}
|
||||
if (this.myVaultOnly && cipherPassesFilter) {
|
||||
cipherPassesFilter = cipher.organizationId === null;
|
||||
cipherPassesFilter = cipher.organizationId == null;
|
||||
}
|
||||
return cipherPassesFilter;
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ export enum FeatureFlag {
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
|
||||
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM19315EndUserActivationMvp]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/key-management";
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
@@ -20,6 +21,7 @@ import { AttachmentView } from "../models/view/attachment.view";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { FieldView } from "../models/view/field.view";
|
||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export type EncryptionContext = {
|
||||
cipher: Cipher;
|
||||
@@ -29,6 +31,7 @@ export type EncryptionContext = {
|
||||
|
||||
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
|
||||
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
|
||||
abstract cipherListViews$(userId: UserId): Observable<CipherListView[] | CipherView[]>;
|
||||
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
|
||||
abstract localData$(userId: UserId): Observable<Record<CipherId, LocalData>>;
|
||||
/**
|
||||
@@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
): Promise<CipherView[]>;
|
||||
abstract filterCiphersForUrl(
|
||||
ciphers: CipherView[],
|
||||
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
|
||||
ciphers: C[],
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
): Promise<CipherView[]>;
|
||||
): Promise<C[]>;
|
||||
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
|
||||
/**
|
||||
* 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,
|
||||
admin: boolean,
|
||||
): Promise<CipherData>;
|
||||
abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number;
|
||||
abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number;
|
||||
abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number;
|
||||
abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract softDeleteWithServer(id: 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,
|
||||
userId: UserId,
|
||||
): 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>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class SearchService {
|
||||
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
|
||||
@@ -16,12 +17,16 @@ export abstract class SearchService {
|
||||
ciphersToIndex: CipherView[],
|
||||
indexedEntityGuid?: string,
|
||||
) => Promise<void>;
|
||||
searchCiphers: (
|
||||
searchCiphers: <C extends CipherViewLike>(
|
||||
userId: UserId,
|
||||
query: string,
|
||||
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
|
||||
ciphers?: CipherView[],
|
||||
) => Promise<CipherView[]>;
|
||||
searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[];
|
||||
filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[],
|
||||
ciphers?: C[],
|
||||
) => Promise<C[]>;
|
||||
searchCiphersBasic: <C extends CipherViewLike>(
|
||||
ciphers: C[],
|
||||
query: string,
|
||||
deleted?: boolean,
|
||||
) => C[];
|
||||
searchSends: (sends: SendView[], query: string) => SendView[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export interface CipherIconDetails {
|
||||
imageEnabled: boolean;
|
||||
@@ -14,7 +14,7 @@ export interface CipherIconDetails {
|
||||
|
||||
export function buildCipherIcon(
|
||||
iconsServerUrl: string | null,
|
||||
cipher: CipherView,
|
||||
cipher: CipherViewLike,
|
||||
showFavicon: boolean,
|
||||
): CipherIconDetails {
|
||||
let icon: string = "bwi-globe";
|
||||
@@ -36,12 +36,16 @@ export function buildCipherIcon(
|
||||
showFavicon = false;
|
||||
}
|
||||
|
||||
switch (cipher.type) {
|
||||
const cipherType = CipherViewLikeUtils.getType(cipher);
|
||||
const uri = CipherViewLikeUtils.uri(cipher);
|
||||
const card = CipherViewLikeUtils.getCard(cipher);
|
||||
|
||||
switch (cipherType) {
|
||||
case CipherType.Login:
|
||||
icon = "bwi-globe";
|
||||
|
||||
if (cipher.login.uri) {
|
||||
let hostnameUri = cipher.login.uri;
|
||||
if (uri) {
|
||||
let hostnameUri = uri;
|
||||
let isWebsite = false;
|
||||
|
||||
if (hostnameUri.indexOf("androidapp://") === 0) {
|
||||
@@ -84,8 +88,8 @@ export function buildCipherIcon(
|
||||
break;
|
||||
case CipherType.Card:
|
||||
icon = "bwi-credit-card";
|
||||
if (showFavicon && cipher.card.brand in cardIcons) {
|
||||
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`;
|
||||
if (showFavicon && card?.brand && card.brand in cardIcons) {
|
||||
icon = `credit-card-icon ${cardIcons[card.brand]}`;
|
||||
}
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
|
||||
@@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { getUserId } from "../../auth/services/account.service";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
/**
|
||||
* Represents either a cipher or a cipher view.
|
||||
*/
|
||||
type CipherLike = Cipher | CipherView;
|
||||
import { CipherLike } from "../types/cipher-like";
|
||||
|
||||
/**
|
||||
* Service for managing user cipher authorization.
|
||||
@@ -95,7 +89,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
|
||||
}
|
||||
}
|
||||
|
||||
return cipher.permissions.delete;
|
||||
return !!cipher.permissions?.delete;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -118,7 +112,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
|
||||
}
|
||||
}
|
||||
|
||||
return cipher.permissions.restore;
|
||||
return !!cipher.permissions?.restore;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view";
|
||||
import { FieldView } from "../models/view/field.view";
|
||||
import { PasswordHistoryView } from "../models/view/password-history.view";
|
||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
import {
|
||||
ADD_EDIT_CIPHER_INFO_KEY,
|
||||
@@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable that emits an array of decrypted ciphers for given userId.
|
||||
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
|
||||
*
|
||||
* This uses the SDK for decryption, when the `PM22134SdkCipherListView` feature flag is disabled the full `cipherViews$` observable will be emitted.
|
||||
* Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported.
|
||||
*/
|
||||
cipherListViews$ = perUserCache$((userId: UserId) => {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe(
|
||||
switchMap((useSdk) => {
|
||||
if (!useSdk) {
|
||||
return this.cipherViews$(userId);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.encryptedCiphersState(userId).state$,
|
||||
this.localData$(userId),
|
||||
this.keyService.cipherDecryptionKeys$(userId, true),
|
||||
]).pipe(
|
||||
filter(([cipherDataState, _, keys]) => cipherDataState != null && keys != null),
|
||||
map(([cipherDataState, localData]) =>
|
||||
Object.values(cipherDataState).map(
|
||||
(cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]),
|
||||
),
|
||||
),
|
||||
switchMap(async (ciphers) => {
|
||||
// TODO: remove this once failed decrypted ciphers are handled in the SDK
|
||||
await this.setFailedDecryptedCiphers([], userId);
|
||||
return this.cipherEncryptionService
|
||||
.decryptMany(ciphers, userId)
|
||||
.then((ciphers) => ciphers.sort(this.getLocaleSortingFunction()));
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Observable that emits an array of decrypted ciphers for the active user.
|
||||
* This observable will not emit until the encrypted ciphers have either been loaded from state or after sync.
|
||||
@@ -543,18 +581,23 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
filter((c) => c != null),
|
||||
switchMap(
|
||||
async (ciphers) =>
|
||||
await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch),
|
||||
await this.filterCiphersForUrl<CipherView>(
|
||||
ciphers,
|
||||
url,
|
||||
includeOtherTypes,
|
||||
defaultMatch,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async filterCiphersForUrl(
|
||||
ciphers: CipherView[],
|
||||
async filterCiphersForUrl<C extends CipherViewLike>(
|
||||
ciphers: C[],
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
): Promise<CipherView[]> {
|
||||
): Promise<C[]> {
|
||||
if (url == null && includeOtherTypes == null) {
|
||||
return [];
|
||||
}
|
||||
@@ -565,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
|
||||
|
||||
return ciphers.filter((cipher) => {
|
||||
const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null;
|
||||
const type = CipherViewLikeUtils.getType(cipher);
|
||||
const login = CipherViewLikeUtils.getLogin(cipher);
|
||||
const cipherIsLogin = login !== null;
|
||||
|
||||
if (cipher.deletedDate !== null) {
|
||||
if (CipherViewLikeUtils.isDeleted(cipher)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(includeOtherTypes) &&
|
||||
includeOtherTypes.includes(cipher.type) &&
|
||||
!cipherIsLogin
|
||||
) {
|
||||
if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cipherIsLogin) {
|
||||
return cipher.login.matchesUri(url, equivalentDomains, defaultMatch);
|
||||
return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1173,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
|
||||
}
|
||||
|
||||
sortCiphersByLastUsed(a: CipherView, b: CipherView): number {
|
||||
sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number {
|
||||
const aLastUsed =
|
||||
a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null;
|
||||
const bLastUsed =
|
||||
@@ -1197,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return 0;
|
||||
}
|
||||
|
||||
sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number {
|
||||
sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number {
|
||||
const result = this.sortCiphersByLastUsed(a, b);
|
||||
if (result !== 0) {
|
||||
return result;
|
||||
@@ -1206,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return this.getLocaleSortingFunction()(a, b);
|
||||
}
|
||||
|
||||
getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number {
|
||||
getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number {
|
||||
return (a, b) => {
|
||||
let aName = a.name;
|
||||
let bName = b.name;
|
||||
@@ -1225,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
? this.i18nService.collator.compare(aName, bName)
|
||||
: aName.localeCompare(bName);
|
||||
|
||||
if (result !== 0 || a.type !== CipherType.Login || b.type !== CipherType.Login) {
|
||||
const aType = CipherViewLikeUtils.getType(a);
|
||||
const bType = CipherViewLikeUtils.getType(b);
|
||||
|
||||
if (result !== 0 || aType !== CipherType.Login || bType !== CipherType.Login) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (a.login.username != null) {
|
||||
aName += a.login.username;
|
||||
const aLogin = CipherViewLikeUtils.getLogin(a);
|
||||
const bLogin = CipherViewLikeUtils.getLogin(b);
|
||||
|
||||
if (aLogin.username != null) {
|
||||
aName += aLogin.username;
|
||||
}
|
||||
|
||||
if (b.login.username != null) {
|
||||
bName += b.login.username;
|
||||
if (bLogin.username != null) {
|
||||
bName += bLogin.username;
|
||||
}
|
||||
|
||||
return this.i18nService.collator
|
||||
@@ -1902,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
|
||||
return decryptedViews.sort(this.getLocaleSortingFunction());
|
||||
}
|
||||
|
||||
/** Fetches the full `CipherView` when a `CipherListView` is passed. */
|
||||
async getFullCipherView(c: CipherViewLike): Promise<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,15 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { CipherLike } from "../types/cipher-like";
|
||||
import { CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export type RestrictedCipherType = {
|
||||
cipherType: CipherType;
|
||||
allowViewOrgIds: string[];
|
||||
};
|
||||
|
||||
type CipherLike = Cipher | CipherView;
|
||||
|
||||
export class RestrictedItemTypesService {
|
||||
/**
|
||||
* Emits an array of RestrictedCipherType objects:
|
||||
@@ -94,7 +92,9 @@ export class RestrictedItemTypesService {
|
||||
* - Otherwise → restricted
|
||||
*/
|
||||
isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean {
|
||||
const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type);
|
||||
const restriction = restrictedTypes.find(
|
||||
(r) => r.cipherType === CipherViewLikeUtils.getType(cipher),
|
||||
);
|
||||
|
||||
// If cipher type is not restricted by any organization, allow it
|
||||
if (!restriction) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export type SerializedLunrIndex = {
|
||||
version: string;
|
||||
@@ -197,13 +198,13 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
]);
|
||||
}
|
||||
|
||||
async searchCiphers(
|
||||
async searchCiphers<C extends CipherViewLike>(
|
||||
userId: UserId,
|
||||
query: string,
|
||||
filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null,
|
||||
ciphers: CipherView[],
|
||||
): Promise<CipherView[]> {
|
||||
const results: CipherView[] = [];
|
||||
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
|
||||
ciphers: C[],
|
||||
): Promise<C[]> {
|
||||
const results: C[] = [];
|
||||
if (query != null) {
|
||||
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
|
||||
}
|
||||
@@ -218,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
if (filter != null && Array.isArray(filter) && filter.length > 0) {
|
||||
ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c)));
|
||||
} else if (filter != null) {
|
||||
ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean);
|
||||
ciphers = ciphers.filter(filter as (cipher: C) => boolean);
|
||||
}
|
||||
|
||||
if (!(await this.isSearchable(userId, query))) {
|
||||
@@ -238,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return this.searchCiphersBasic(ciphers, query);
|
||||
}
|
||||
|
||||
const ciphersMap = new Map<string, CipherView>();
|
||||
const ciphersMap = new Map<string, C>();
|
||||
ciphers.forEach((c) => ciphersMap.set(c.id, c));
|
||||
|
||||
let searchResults: lunr.Index.Result[] = null;
|
||||
@@ -272,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return results;
|
||||
}
|
||||
|
||||
searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) {
|
||||
searchCiphersBasic<C extends CipherViewLike>(ciphers: C[], query: string, deleted = false) {
|
||||
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
|
||||
return ciphers.filter((c) => {
|
||||
if (deleted !== c.isDeleted) {
|
||||
if (deleted !== CipherViewLikeUtils.isDeleted(c)) {
|
||||
return false;
|
||||
}
|
||||
if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) {
|
||||
@@ -284,13 +285,17 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
if (query.length >= 8 && c.id.startsWith(query)) {
|
||||
return true;
|
||||
}
|
||||
if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) {
|
||||
const subtitle = CipherViewLikeUtils.subtitle(c);
|
||||
if (subtitle != null && subtitle.toLowerCase().indexOf(query) > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
|
||||
if (
|
||||
c.login &&
|
||||
c.login.hasUris &&
|
||||
c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
|
||||
login &&
|
||||
login.uris.length &&
|
||||
login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
9
libs/common/src/vault/types/cipher-like.ts
Normal file
9
libs/common/src/vault/types/cipher-like.ts
Normal 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;
|
||||
624
libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
Normal file
624
libs/common/src/vault/utils/cipher-view-like-utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
301
libs/common/src/vault/utils/cipher-view-like-utils.ts
Normal file
301
libs/common/src/vault/utils/cipher-view-like-utils.ts
Normal 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;
|
||||
};
|
||||
@@ -1,3 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components";
|
||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||
@@ -9,23 +15,31 @@ describe("CopyCipherFieldDirective", () => {
|
||||
copy: jest.fn().mockResolvedValue(null),
|
||||
totpAllowed: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
let mockAccountService: AccountService;
|
||||
let mockCipherService: CipherService;
|
||||
|
||||
let copyCipherFieldDirective: CopyCipherFieldDirective;
|
||||
|
||||
beforeEach(() => {
|
||||
copyFieldService.copy.mockClear();
|
||||
copyFieldService.totpAllowed.mockClear();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockAccountService.activeAccount$ = of({ id: "test-account-id" } as Account);
|
||||
mockCipherService = mock<CipherService>();
|
||||
|
||||
copyCipherFieldDirective = new CopyCipherFieldDirective(
|
||||
copyFieldService as unknown as CopyCipherFieldService,
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
);
|
||||
copyCipherFieldDirective.cipher = new CipherView();
|
||||
copyCipherFieldDirective.cipher.type = CipherType.Login;
|
||||
});
|
||||
|
||||
describe("disabled state", () => {
|
||||
it("should be enabled when the field is available", async () => {
|
||||
copyCipherFieldDirective.action = "username";
|
||||
copyCipherFieldDirective.cipher.login.username = "test-username";
|
||||
(copyCipherFieldDirective.cipher as CipherView).login.username = "test-username";
|
||||
|
||||
await copyCipherFieldDirective.ngOnChanges();
|
||||
|
||||
@@ -35,6 +49,7 @@ describe("CopyCipherFieldDirective", () => {
|
||||
it("should be disabled when the field is not available", async () => {
|
||||
// create empty cipher
|
||||
copyCipherFieldDirective.cipher = new CipherView();
|
||||
copyCipherFieldDirective.cipher.type = CipherType.Login;
|
||||
|
||||
copyCipherFieldDirective.action = "username";
|
||||
|
||||
@@ -52,11 +67,15 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
copyCipherFieldDirective = new CopyCipherFieldDirective(
|
||||
copyFieldService as unknown as CopyCipherFieldService,
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
undefined,
|
||||
iconButton as unknown as BitIconButtonComponent,
|
||||
);
|
||||
|
||||
copyCipherFieldDirective.action = "password";
|
||||
copyCipherFieldDirective.cipher = new CipherView();
|
||||
copyCipherFieldDirective.cipher.type = CipherType.Login;
|
||||
|
||||
await copyCipherFieldDirective.ngOnChanges();
|
||||
|
||||
@@ -70,6 +89,8 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
copyCipherFieldDirective = new CopyCipherFieldDirective(
|
||||
copyFieldService as unknown as CopyCipherFieldService,
|
||||
mockAccountService,
|
||||
mockCipherService,
|
||||
menuItemDirective as unknown as MenuItemDirective,
|
||||
);
|
||||
|
||||
@@ -83,9 +104,11 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
describe("login", () => {
|
||||
beforeEach(() => {
|
||||
copyCipherFieldDirective.cipher.login.username = "test-username";
|
||||
copyCipherFieldDirective.cipher.login.password = "test-password";
|
||||
copyCipherFieldDirective.cipher.login.totp = "test-totp";
|
||||
const cipher = copyCipherFieldDirective.cipher as CipherView;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login.username = "test-username";
|
||||
cipher.login.password = "test-password";
|
||||
cipher.login.totp = "test-totp";
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -107,10 +130,12 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
describe("identity", () => {
|
||||
beforeEach(() => {
|
||||
copyCipherFieldDirective.cipher.identity.username = "test-username";
|
||||
copyCipherFieldDirective.cipher.identity.email = "test-email";
|
||||
copyCipherFieldDirective.cipher.identity.phone = "test-phone";
|
||||
copyCipherFieldDirective.cipher.identity.address1 = "test-address-1";
|
||||
const cipher = copyCipherFieldDirective.cipher as CipherView;
|
||||
cipher.type = CipherType.Identity;
|
||||
cipher.identity.username = "test-username";
|
||||
cipher.identity.email = "test-email";
|
||||
cipher.identity.phone = "test-phone";
|
||||
cipher.identity.address1 = "test-address-1";
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -133,8 +158,10 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
describe("card", () => {
|
||||
beforeEach(() => {
|
||||
copyCipherFieldDirective.cipher.card.number = "test-card-number";
|
||||
copyCipherFieldDirective.cipher.card.code = "test-card-code";
|
||||
const cipher = copyCipherFieldDirective.cipher as CipherView;
|
||||
cipher.type = CipherType.Card;
|
||||
cipher.card.number = "test-card-number";
|
||||
cipher.card.code = "test-card-code";
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -155,7 +182,9 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
describe("secure note", () => {
|
||||
beforeEach(() => {
|
||||
copyCipherFieldDirective.cipher.notes = "test-secure-note";
|
||||
const cipher = copyCipherFieldDirective.cipher as CipherView;
|
||||
cipher.type = CipherType.SecureNote;
|
||||
cipher.notes = "test-secure-note";
|
||||
});
|
||||
|
||||
it("copies secure note field to clipboard", async () => {
|
||||
@@ -173,9 +202,11 @@ describe("CopyCipherFieldDirective", () => {
|
||||
|
||||
describe("ssh key", () => {
|
||||
beforeEach(() => {
|
||||
copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key";
|
||||
copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key";
|
||||
copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint";
|
||||
const cipher = copyCipherFieldDirective.cipher as CipherView;
|
||||
cipher.type = CipherType.SshKey;
|
||||
cipher.sshKey.privateKey = "test-private-key";
|
||||
cipher.sshKey.publicKey = "test-public-key";
|
||||
cipher.sshKey.keyFingerprint = "test-key-fingerprint";
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
|
||||
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
@@ -27,10 +35,12 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
})
|
||||
action!: Exclude<CopyAction, "hiddenField">;
|
||||
|
||||
@Input({ required: true }) cipher!: CipherView;
|
||||
@Input({ required: true }) cipher!: CipherViewLike;
|
||||
|
||||
constructor(
|
||||
private copyCipherFieldService: CopyCipherFieldService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
@Optional() private menuItemDirective?: MenuItemDirective,
|
||||
@Optional() private iconButtonComponent?: BitIconButtonComponent,
|
||||
) {}
|
||||
@@ -49,7 +59,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
|
||||
@HostListener("click")
|
||||
async copy() {
|
||||
const value = this.getValueToCopy();
|
||||
const value = await this.getValueToCopy();
|
||||
await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher);
|
||||
}
|
||||
|
||||
@@ -60,7 +70,7 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
private async updateDisabledState() {
|
||||
this.disabled =
|
||||
!this.cipher ||
|
||||
!this.getValueToCopy() ||
|
||||
!this.hasValueToCopy() ||
|
||||
(this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher)))
|
||||
? true
|
||||
: null;
|
||||
@@ -76,32 +86,51 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
private getValueToCopy() {
|
||||
/** Returns `true` when the cipher has the associated value as populated. */
|
||||
private hasValueToCopy() {
|
||||
return CipherViewLikeUtils.hasCopyableValue(this.cipher, this.action);
|
||||
}
|
||||
|
||||
/** Returns the value of the cipher to be copied. */
|
||||
private async getValueToCopy() {
|
||||
let _cipher: CipherView;
|
||||
|
||||
if (CipherViewLikeUtils.isCipherListView(this.cipher)) {
|
||||
// When the cipher is of type `CipherListView`, the full cipher needs to be decrypted
|
||||
const activeAccountId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
const encryptedCipher = await this.cipherService.get(this.cipher.id!, activeAccountId);
|
||||
_cipher = await this.cipherService.decrypt(encryptedCipher, activeAccountId);
|
||||
} else {
|
||||
_cipher = this.cipher;
|
||||
}
|
||||
|
||||
switch (this.action) {
|
||||
case "username":
|
||||
return this.cipher.login?.username || this.cipher.identity?.username;
|
||||
return _cipher.login?.username || _cipher.identity?.username;
|
||||
case "password":
|
||||
return this.cipher.login?.password;
|
||||
return _cipher.login?.password;
|
||||
case "totp":
|
||||
return this.cipher.login?.totp;
|
||||
return _cipher.login?.totp;
|
||||
case "cardNumber":
|
||||
return this.cipher.card?.number;
|
||||
return _cipher.card?.number;
|
||||
case "securityCode":
|
||||
return this.cipher.card?.code;
|
||||
return _cipher.card?.code;
|
||||
case "email":
|
||||
return this.cipher.identity?.email;
|
||||
return _cipher.identity?.email;
|
||||
case "phone":
|
||||
return this.cipher.identity?.phone;
|
||||
return _cipher.identity?.phone;
|
||||
case "address":
|
||||
return this.cipher.identity?.fullAddressForCopy;
|
||||
return _cipher.identity?.fullAddressForCopy;
|
||||
case "secureNote":
|
||||
return this.cipher.notes;
|
||||
return _cipher.notes;
|
||||
case "privateKey":
|
||||
return this.cipher.sshKey?.privateKey;
|
||||
return _cipher.sshKey?.privateKey;
|
||||
case "publicKey":
|
||||
return this.cipher.sshKey?.publicKey;
|
||||
return _cipher.sshKey?.publicKey;
|
||||
case "keyFingerprint":
|
||||
return this.cipher.sshKey?.keyFingerprint;
|
||||
return _cipher.sshKey?.keyFingerprint;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
@@ -128,6 +128,7 @@ describe("CopyCipherFieldService", () => {
|
||||
describe("totp", () => {
|
||||
beforeEach(() => {
|
||||
actionType = "totp";
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.totp = "secret-totp";
|
||||
cipher.reprompt = CipherRepromptType.None;
|
||||
|
||||
@@ -9,7 +9,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@@ -103,7 +106,7 @@ export class CopyCipherFieldService {
|
||||
async copy(
|
||||
valueToCopy: string,
|
||||
actionType: CopyAction,
|
||||
cipher: CipherView,
|
||||
cipher: CipherViewLike,
|
||||
skipReprompt: boolean = false,
|
||||
): Promise<boolean> {
|
||||
const action = CopyActions[actionType];
|
||||
@@ -153,13 +156,16 @@ export class CopyCipherFieldService {
|
||||
/**
|
||||
* Determines if TOTP generation is allowed for a cipher and user.
|
||||
*/
|
||||
async totpAllowed(cipher: CipherView): Promise<boolean> {
|
||||
async totpAllowed(cipher: CipherViewLike): Promise<boolean> {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const login = CipherViewLikeUtils.getLogin(cipher);
|
||||
|
||||
return (
|
||||
(cipher?.login?.hasTotp ?? false) &&
|
||||
!!login?.totp &&
|
||||
(cipher.organizationUseTotp ||
|
||||
(await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeAccount.id),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { PasswordRepromptComponent } from "../components/password-reprompt.component";
|
||||
@@ -28,7 +28,7 @@ export class PasswordRepromptService {
|
||||
return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"];
|
||||
}
|
||||
|
||||
async passwordRepromptCheck(cipher: CipherView) {
|
||||
async passwordRepromptCheck(cipher: CipherViewLike) {
|
||||
if (cipher.reprompt === CipherRepromptType.None) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user