import { CommonModule } from "@angular/common"; import { Component, computed, input } from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { combineLatest, of, switchMap, map, catchError, from, Observable, startWith } 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 { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; import { CipherId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; import { CipherRiskService, isPasswordAtRisk, } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { CalloutModule, SearchModule, TypographyModule, AnchorLinkDirective, } from "@bitwarden/components"; import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service"; import { AdditionalOptionsComponent } from "./additional-options/additional-options.component"; import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component"; import { AutofillOptionsViewComponent } from "./autofill-options/autofill-options-view.component"; import { CardDetailsComponent } from "./card-details/card-details-view.component"; import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component"; import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component"; import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-cipher-view", templateUrl: "cipher-view.component.html", imports: [ CalloutModule, CommonModule, SearchModule, JslibModule, ItemDetailsV2Component, AdditionalOptionsComponent, AttachmentsV2ViewComponent, ItemHistoryV2Component, CustomFieldV2Component, CardDetailsComponent, SshKeyViewComponent, ViewIdentitySectionsComponent, LoginCredentialsViewComponent, AutofillOptionsViewComponent, AnchorLinkDirective, TypographyModule, ], }) export class CipherViewComponent { /** * The cipher to display details for */ readonly cipher = input.required(); /** * Observable version of the cipher input */ private readonly cipher$ = toObservable(this.cipher); /** * Required for fetching attachment data when viewed from cipher via emergency access */ readonly emergencyAccessId = input(); /** * Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the * `CipherService` and the `collectionIds` property of the cipher. */ readonly collections = input(undefined); /** * Should be set to true when the component is used within the Admin Console */ readonly isAdminConsole = input(false); readonly activeUserId$ = getUserId(this.accountService.activeAccount$); constructor( private organizationService: OrganizationService, private collectionService: CollectionService, private folderService: FolderService, private accountService: AccountService, private defaultTaskService: TaskService, private platformUtilsService: PlatformUtilsService, private changeLoginPasswordService: ChangeLoginPasswordService, private cipherService: CipherService, private logService: LogService, private cipherRiskService: CipherRiskService, private billingAccountService: BillingAccountProfileStateService, private configService: ConfigService, ) {} readonly resolvedCollections = toSignal( combineLatest([this.activeUserId$, this.cipher$, toObservable(this.collections)]).pipe( switchMap(([userId, cipher, providedCollections]) => { // Use provided collections if available if (providedCollections && providedCollections.length > 0) { return of(providedCollections); } // Otherwise, load collections based on cipher's collectionIds if (cipher.collectionIds && cipher.collectionIds.length > 0) { return this.collectionService .decryptedCollections$(userId) .pipe(getByIds(cipher.collectionIds)); } return of(undefined); }), ), ); readonly organization = toSignal( combineLatest([this.activeUserId$, this.cipher$]).pipe( switchMap(([userId, cipher]) => { if (!userId || !cipher?.organizationId) { return of(undefined); } return this.organizationService.organizations$(userId).pipe( map((organizations) => { return organizations.find((org) => org.id === cipher.organizationId); }), ); }), ), ); readonly folder = toSignal( combineLatest([this.activeUserId$, this.cipher$]).pipe( switchMap(([userId, cipher]) => { if (!userId || !cipher?.folderId) { return of(undefined); } return this.folderService.getDecrypted$(cipher.folderId, userId); }), ), ); readonly hadPendingChangePasswordTask = toSignal( combineLatest([this.activeUserId$, this.cipher$]).pipe( switchMap(([userId, cipher]) => { // Early exit if not a Login cipher owned by an organization if (cipher?.type !== CipherType.Login || !cipher?.organizationId) { return of(false); } return combineLatest([ this.cipherService.ciphers$(userId), this.defaultTaskService.pendingTasks$(userId), ]).pipe( map(([allCiphers, tasks]) => { const cipherServiceCipher = allCiphers[cipher?.id as CipherId]; // Show tasks only for Manage and Edit permissions if (!cipherServiceCipher?.edit || !cipherServiceCipher?.viewPassword) { return false; } return ( tasks?.some( (task) => task.cipherId === cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential, ) ?? false ); }), catchError((error: unknown) => { this.logService.error("Failed to retrieve change password tasks for cipher", error); return of(false); }), ); }), ), { initialValue: false }, ); readonly hasCard = computed(() => { const cipher = this.cipher(); if (!cipher) { return false; } const { cardholderName, code, expMonth, expYear, number } = cipher.card; return cardholderName || code || expMonth || expYear || number; }); readonly cardIsExpired = computed(() => { const cipher = this.cipher(); if (cipher == null) { return false; } return isCardExpired(cipher.card); }); readonly hasLogin = computed(() => { const cipher = this.cipher(); if (!cipher) { return false; } const { username, password, totp, fido2Credentials } = cipher.login; return username || password || totp || fido2Credentials?.length > 0; }); readonly hasAutofill = computed(() => { const cipher = this.cipher(); const uris = cipher?.login?.uris.length ?? 0; return uris > 0; }); readonly hasSshKey = computed(() => { const cipher = this.cipher(); return !!cipher?.sshKey?.privateKey; }); readonly hasLoginUri = computed(() => { const cipher = this.cipher(); return cipher?.login?.hasUris; }); /** * Whether the login password for the cipher is considered at risk. * The password is only evaluated when the user is premium and has edit access to the cipher. */ readonly passwordIsAtRisk = toSignal( combineLatest([ this.activeUserId$, this.cipher$, this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium), ]).pipe( switchMap(([userId, cipher, featureEnabled]) => { if ( !featureEnabled || !cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted ) { return of(false); } return this.switchPremium$( userId, () => from(this.checkIfPasswordIsAtRisk(cipher.id as CipherId, userId as UserId)).pipe( startWith(false), ), () => of(false), ); }), ), { initialValue: false }, ); readonly showChangePasswordLink = computed(() => { return this.hasLoginUri() && (this.hadPendingChangePasswordTask() || this.passwordIsAtRisk()); }); launchChangePassword = async () => { const cipher = this.cipher(); if (cipher != null) { const url = await this.changeLoginPasswordService.getChangePasswordUrl(cipher); if (url == null) { return; } this.platformUtilsService.launchUri(url); } }; /** * Switches between two observables based on whether the user has a premium from any source. */ private switchPremium$( userId: UserId, ifPremium$: () => Observable, ifNonPremium$: () => Observable, ): Observable { return this.billingAccountService .hasPremiumFromAnySource$(userId) .pipe(switchMap((isPremium) => (isPremium ? ifPremium$() : ifNonPremium$()))); } private async checkIfPasswordIsAtRisk(cipherId: CipherId, userId: UserId): Promise { try { const risk = await this.cipherRiskService.computeCipherRiskForUser(cipherId, userId, true); return isPasswordAtRisk(risk); } catch (error: unknown) { this.logService.error("Failed to check if password is at risk", error); return false; } } }