mirror of
https://github.com/bitwarden/browser
synced 2026-01-06 02:23:44 +00:00
[PM-24469] Implement Risk Insights for Premium in Cipher view component (#17012)
* [PM-24469] Refactor CipherViewComponent to use Angular signals and computed properties for improved reactivity * [PM-24469] Refactor CipherViewComponent to utilize Angular signals for organization data retrieval * [PM-24469] Refactor CipherViewComponent to utilize Angular signals for folder data retrieval * [PM-24469] Cleanup organization signal * [PM-24469] Refactor CipherViewComponent to replace signal for card expiration with computed property * [PM-24469] Improve collections loading in CipherViewComponent * [PM-24469] Remove redundant loadCipherData method * [PM-24469] Refactor CipherViewComponent to replace signal with computed property for pending change password tasks * [PM-24469] Refactor LoginCredentialsViewComponent to rename hadPendingChangePasswordTask to showChangePasswordLink for clarity * [PM-24469] Introduce showChangePasswordLink computed property for improved readability * [PM-24469] Initial RI for premium logic * [PM-24469] Refactor checkPassword risk checking logic * [PM-24469] Cleanup premium check * [PM-24469] Cleanup UI visuals * [PM-24469] Fix missing typography import * [PM-24469] Cleanup docs * [PM-24469] Add feature flag * [PM-24469] Ensure password risk check is only performed when the feature is enabled, and the cipher is editable by the user, and it has a password * [PM-24469] Refactor password risk evaluation logic and add unit tests for risk assessment * [PM-24469] Fix mismatched CipherId type * [PM-24469] Fix test dependencies * [PM-24469] Fix config service mock in emergency view dialog spec * [PM-24469] Wait for decrypted vault before calculating cipher risk * [PM-24469] startWith(false) for passwordIsAtRisk signal to avoid showing stale values when cipher changes * [PM-24469] Exclude organization owned ciphers from JIT risk analysis * [PM-24469] Add initial cipher-view component test boilerplate * [PM-24469] Add passwordIsAtRisk signal tests * [PM-24469] Ignore soft deleted items for RI for premium feature * [PM-24469] Fix tests
This commit is contained in:
@@ -1,30 +1,38 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
|
||||
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
|
||||
import { ChangeDetectionStrategy, 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 {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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 { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { AnchorLinkDirective, CalloutModule, SearchModule } from "@bitwarden/components";
|
||||
import {
|
||||
CalloutModule,
|
||||
SearchModule,
|
||||
TypographyModule,
|
||||
AnchorLinkDirective,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
|
||||
|
||||
@@ -39,11 +47,10 @@ import { LoginCredentialsViewComponent } from "./login-credentials/login-credent
|
||||
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",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CalloutModule,
|
||||
CommonModule,
|
||||
@@ -60,38 +67,37 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
||||
LoginCredentialsViewComponent,
|
||||
AutofillOptionsViewComponent,
|
||||
AnchorLinkDirective,
|
||||
TypographyModule,
|
||||
],
|
||||
})
|
||||
export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) cipher: CipherView | null = null;
|
||||
export class CipherViewComponent {
|
||||
/**
|
||||
* The cipher to display details for
|
||||
*/
|
||||
readonly cipher = input.required<CipherView>();
|
||||
|
||||
// Required for fetching attachment data when viewed from cipher via emergency access
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() emergencyAccessId?: EmergencyAccessId;
|
||||
/**
|
||||
* Observable version of the cipher input
|
||||
*/
|
||||
private readonly cipher$ = toObservable(this.cipher);
|
||||
|
||||
activeUserId$ = getUserId(this.accountService.activeAccount$);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() collections?: CollectionView[];
|
||||
readonly collections = input<CollectionView[] | undefined>(undefined);
|
||||
|
||||
/** Should be set to true when the component is used within the Admin Console */
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() isAdminConsole?: boolean = false;
|
||||
/**
|
||||
* Should be set to true when the component is used within the Admin Console
|
||||
*/
|
||||
readonly isAdminConsole = input<boolean>(false);
|
||||
|
||||
organization$: Observable<Organization | undefined> | undefined;
|
||||
folder$: Observable<FolderView | undefined> | undefined;
|
||||
private destroyed$: Subject<void> = new Subject();
|
||||
cardIsExpired: boolean = false;
|
||||
hadPendingChangePasswordTask: boolean = false;
|
||||
readonly activeUserId$ = getUserId(this.accountService.activeAccount$);
|
||||
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
@@ -103,126 +109,206 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
private changeLoginPasswordService: ChangeLoginPasswordService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private cipherRiskService: CipherRiskService,
|
||||
private billingAccountService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnChanges() {
|
||||
if (this.cipher == null) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.loadCipherData();
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.cardIsExpired = isCardExpired(this.cipher.card);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed$.next();
|
||||
this.destroyed$.complete();
|
||||
}
|
||||
return combineLatest([
|
||||
this.cipherService.ciphers$(userId),
|
||||
this.defaultTaskService.pendingTasks$(userId),
|
||||
]).pipe(
|
||||
map(([allCiphers, tasks]) => {
|
||||
const cipherServiceCipher = allCiphers[cipher?.id as CipherId];
|
||||
|
||||
get hasCard() {
|
||||
if (!this.cipher) {
|
||||
// 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 } = this.cipher.card;
|
||||
const { cardholderName, code, expMonth, expYear, number } = cipher.card;
|
||||
return cardholderName || code || expMonth || expYear || number;
|
||||
}
|
||||
});
|
||||
|
||||
get hasLogin() {
|
||||
if (!this.cipher) {
|
||||
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 } = this.cipher.login;
|
||||
const { username, password, totp, fido2Credentials } = cipher.login;
|
||||
|
||||
return username || password || totp || fido2Credentials?.length > 0;
|
||||
}
|
||||
});
|
||||
|
||||
get hasAutofill() {
|
||||
const uris = this.cipher?.login?.uris.length ?? 0;
|
||||
readonly hasAutofill = computed(() => {
|
||||
const cipher = this.cipher();
|
||||
const uris = cipher?.login?.uris.length ?? 0;
|
||||
|
||||
return uris > 0;
|
||||
}
|
||||
});
|
||||
|
||||
get hasSshKey() {
|
||||
return !!this.cipher?.sshKey?.privateKey;
|
||||
}
|
||||
readonly hasSshKey = computed(() => {
|
||||
const cipher = this.cipher();
|
||||
return !!cipher?.sshKey?.privateKey;
|
||||
});
|
||||
|
||||
get hasLoginUri() {
|
||||
return this.cipher?.login?.hasUris;
|
||||
}
|
||||
readonly hasLoginUri = computed(() => {
|
||||
const cipher = this.cipher();
|
||||
return cipher?.login?.hasUris;
|
||||
});
|
||||
|
||||
async loadCipherData() {
|
||||
if (!this.cipher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(this.activeUserId$);
|
||||
|
||||
// Load collections if not provided and the cipher has collectionIds
|
||||
if (
|
||||
this.cipher.collectionIds &&
|
||||
this.cipher.collectionIds.length > 0 &&
|
||||
(!this.collections || this.collections.length === 0)
|
||||
) {
|
||||
this.collections = await firstValueFrom(
|
||||
this.collectionService
|
||||
.decryptedCollections$(userId)
|
||||
.pipe(getByIds(this.cipher.collectionIds)),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.cipher.organizationId) {
|
||||
this.organization$ = this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(getOrganizationById(this.cipher.organizationId))
|
||||
.pipe(takeUntil(this.destroyed$));
|
||||
|
||||
if (this.cipher.type === CipherType.Login) {
|
||||
await this.checkPendingChangePasswordTasks(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
this.folder$ = this.folderService
|
||||
.getDecrypted$(this.cipher.folderId, userId)
|
||||
.pipe(takeUntil(this.destroyed$));
|
||||
}
|
||||
}
|
||||
|
||||
async checkPendingChangePasswordTasks(userId: UserId): Promise<void> {
|
||||
try {
|
||||
// Show Tasks for Manage and Edit permissions
|
||||
// Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions
|
||||
const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId];
|
||||
|
||||
if (!cipherServiceCipher?.edit || !cipherServiceCipher?.viewPassword) {
|
||||
this.hadPendingChangePasswordTask = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId));
|
||||
|
||||
this.hadPendingChangePasswordTask = tasks?.some((task) => {
|
||||
return (
|
||||
task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
this.hadPendingChangePasswordTask = false;
|
||||
this.logService.error("Failed to retrieve change password tasks for cipher", error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly showChangePasswordLink = computed(() => {
|
||||
return this.hasLoginUri() && (this.hadPendingChangePasswordTask() || this.passwordIsAtRisk());
|
||||
});
|
||||
|
||||
launchChangePassword = async () => {
|
||||
if (this.cipher != null) {
|
||||
const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user