mirror of
https://github.com/bitwarden/browser
synced 2026-01-01 16:13:27 +00:00
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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<CipherView>();
|
|
|
|
/**
|
|
* 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<EmergencyAccessId | undefined>();
|
|
|
|
/**
|
|
* 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<CollectionView[] | undefined>(undefined);
|
|
|
|
/**
|
|
* Should be set to true when the component is used within the Admin Console
|
|
*/
|
|
readonly isAdminConsole = input<boolean>(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<CollectionView[] | undefined>(
|
|
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$<T>(
|
|
userId: UserId,
|
|
ifPremium$: () => Observable<T>,
|
|
ifNonPremium$: () => Observable<T>,
|
|
): Observable<T> {
|
|
return this.billingAccountService
|
|
.hasPremiumFromAnySource$(userId)
|
|
.pipe(switchMap((isPremium) => (isPremium ? ifPremium$() : ifNonPremium$())));
|
|
}
|
|
|
|
private async checkIfPasswordIsAtRisk(cipherId: CipherId, userId: UserId): Promise<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
}
|