1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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:
Shane Melton
2025-11-04 12:15:53 -08:00
committed by GitHub
parent d364dfdda0
commit 7e5f02f90c
15 changed files with 732 additions and 180 deletions

View File

@@ -1,89 +1,85 @@
<ng-container *ngIf="!!cipher">
<bit-callout *ngIf="cardIsExpired" type="info" [title]="'cardExpiredTitle' | i18n">
<ng-container *ngIf="!!cipher()">
<bit-callout *ngIf="cardIsExpired()" type="info" [title]="'cardExpiredTitle' | i18n">
{{ "cardExpiredMessage" | i18n }}
</bit-callout>
<bit-callout
*ngIf="!hasLoginUri && hadPendingChangePasswordTask"
*ngIf="!hasLoginUri() && hadPendingChangePasswordTask()"
type="warning"
[title]="'missingWebsite' | i18n"
>
{{ "changeAtRiskPasswordAndAddWebsite" | i18n }}
</bit-callout>
<bit-callout *ngIf="hasLoginUri && hadPendingChangePasswordTask" type="warning" [title]="''">
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
<bit-callout *ngIf="showChangePasswordLink()" type="warning" [title]="''">
<a bitLink href="#" appStopClick (click)="launchChangePassword()" linkType="secondary">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>
<!-- HELPER TEXT -->
<p
class="tw-text-sm tw-text-muted"
bitTypography="helper"
*ngIf="cipher?.isDeleted && !cipher?.edit"
>
<p class="tw-text-muted" bitTypography="helper" *ngIf="cipher()?.isDeleted && !cipher()?.edit">
{{ "noEditPermissions" | i18n }}
</p>
<!-- ITEM DETAILS -->
<app-item-details-v2
[cipher]="cipher"
[organization]="organization$ | async"
[collections]="collections"
[folder]="folder$ | async"
[hideOwner]="isAdminConsole"
[cipher]="cipher()"
[organization]="organization()"
[collections]="resolvedCollections()"
[folder]="folder()"
[hideOwner]="isAdminConsole()"
>
</app-item-details-v2>
<!-- LOGIN CREDENTIALS -->
<app-login-credentials-view
*ngIf="hasLogin"
[cipher]="cipher"
*ngIf="hasLogin()"
[cipher]="cipher()"
[activeUserId]="activeUserId$ | async"
[hadPendingChangePasswordTask]="hadPendingChangePasswordTask && cipher?.login.uris.length > 0"
[showChangePasswordLink]="showChangePasswordLink()"
(handleChangePassword)="launchChangePassword()"
></app-login-credentials-view>
<!-- AUTOFILL OPTIONS -->
<app-autofill-options-view
*ngIf="hasAutofill"
[loginUris]="cipher.login.uris"
[cipherId]="cipher.id"
*ngIf="hasAutofill()"
[loginUris]="cipher()!.login.uris"
[cipherId]="cipher()!.id"
>
</app-autofill-options-view>
<!-- CARD DETAILS -->
<app-card-details-view *ngIf="hasCard" [cipher]="cipher"></app-card-details-view>
<app-card-details-view *ngIf="hasCard()" [cipher]="cipher()"></app-card-details-view>
<!-- IDENTITY SECTIONS -->
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
<app-view-identity-sections *ngIf="cipher()?.identity" [cipher]="cipher()">
</app-view-identity-sections>
<!-- SshKEY SECTIONS -->
<app-sshkey-view *ngIf="hasSshKey" [sshKey]="cipher.sshKey"></app-sshkey-view>
<app-sshkey-view *ngIf="hasSshKey()" [sshKey]="cipher()!.sshKey"></app-sshkey-view>
<!-- ADDITIONAL OPTIONS -->
<ng-container *ngIf="cipher.notes">
<app-additional-options [notes]="cipher.notes"> </app-additional-options>
<ng-container *ngIf="cipher()?.notes">
<app-additional-options [notes]="cipher()!.notes"> </app-additional-options>
</ng-container>
<!-- CUSTOM FIELDS -->
<ng-container *ngIf="cipher.hasFields">
<app-custom-fields-v2 [cipher]="cipher"> </app-custom-fields-v2>
<ng-container *ngIf="cipher()?.hasFields">
<app-custom-fields-v2 [cipher]="cipher()"> </app-custom-fields-v2>
</ng-container>
<!-- ATTACHMENTS SECTION -->
<ng-container *ngIf="cipher.hasAttachments">
<ng-container *ngIf="cipher()?.hasAttachments">
<app-attachments-v2-view
[emergencyAccessId]="emergencyAccessId"
[cipher]="cipher"
[admin]="isAdminConsole"
[emergencyAccessId]="emergencyAccessId()"
[cipher]="cipher()"
[admin]="isAdminConsole()"
>
</app-attachments-v2-view>
</ng-container>
<!-- ITEM HISTORY SECTION -->
<app-item-history-v2 [cipher]="cipher"> </app-item-history-v2>
<app-item-history-v2 [cipher]="cipher()"> </app-item-history-v2>
</ng-container>

View File

@@ -0,0 +1,287 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherRiskService } 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 { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
import { CipherViewComponent } from "./cipher-view.component";
describe("CipherViewComponent", () => {
let component: CipherViewComponent;
let fixture: ComponentFixture<CipherViewComponent>;
// Mock services
let mockAccountService: AccountService;
let mockOrganizationService: OrganizationService;
let mockCollectionService: CollectionService;
let mockFolderService: FolderService;
let mockTaskService: TaskService;
let mockPlatformUtilsService: PlatformUtilsService;
let mockChangeLoginPasswordService: ChangeLoginPasswordService;
let mockCipherService: CipherService;
let mockViewPasswordHistoryService: ViewPasswordHistoryService;
let mockI18nService: I18nService;
let mockLogService: LogService;
let mockCipherRiskService: CipherRiskService;
let mockBillingAccountProfileStateService: BillingAccountProfileStateService;
let mockConfigService: ConfigService;
// Mock data
let mockCipherView: CipherView;
let featureFlagEnabled$: BehaviorSubject<boolean>;
let hasPremiumFromAnySource$: BehaviorSubject<boolean>;
let activeAccount$: BehaviorSubject<Account>;
beforeEach(async () => {
// Setup mock observables
activeAccount$ = new BehaviorSubject({
id: "test-user-id",
email: "test@example.com",
} as Account);
featureFlagEnabled$ = new BehaviorSubject(false);
hasPremiumFromAnySource$ = new BehaviorSubject(true);
// Create service mocks
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = activeAccount$;
mockOrganizationService = mock<OrganizationService>();
mockCollectionService = mock<CollectionService>();
mockFolderService = mock<FolderService>();
mockTaskService = mock<TaskService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
mockCipherService = mock<CipherService>();
mockViewPasswordHistoryService = mock<ViewPasswordHistoryService>();
mockI18nService = mock<I18nService>({
t: (key: string) => key,
});
mockLogService = mock<LogService>();
mockCipherRiskService = mock<CipherRiskService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockBillingAccountProfileStateService.hasPremiumFromAnySource$ = jest
.fn()
.mockReturnValue(hasPremiumFromAnySource$);
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$);
// Setup mock cipher view
mockCipherView = new CipherView();
mockCipherView.id = "cipher-id";
mockCipherView.name = "Test Cipher";
await TestBed.configureTestingModule({
imports: [CipherViewComponent],
providers: [
{ provide: AccountService, useValue: mockAccountService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: CollectionService, useValue: mockCollectionService },
{ provide: FolderService, useValue: mockFolderService },
{ provide: TaskService, useValue: mockTaskService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: ViewPasswordHistoryService, useValue: mockViewPasswordHistoryService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: LogService, useValue: mockLogService },
{ provide: CipherRiskService, useValue: mockCipherRiskService },
{
provide: BillingAccountProfileStateService,
useValue: mockBillingAccountProfileStateService,
},
{ provide: ConfigService, useValue: mockConfigService },
],
schemas: [NO_ERRORS_SCHEMA],
})
// Override the component template to avoid rendering child components
// Allows testing component logic without
// needing to provide dependencies for all child components.
.overrideComponent(CipherViewComponent, {
set: {
template: "<div>{{ passwordIsAtRisk() }}</div>",
imports: [],
},
})
.compileComponents();
fixture = TestBed.createComponent(CipherViewComponent);
component = fixture.componentInstance;
});
describe("passwordIsAtRisk signal", () => {
// Helper to create a cipher view with login credentials
const createLoginCipherView = (): CipherView => {
const cipher = new CipherView();
cipher.id = "cipher-id";
cipher.name = "Test Login";
cipher.type = CipherType.Login;
cipher.edit = true;
cipher.organizationId = undefined;
// Set up login with password so hasLoginPassword returns true
cipher.login = { password: "test-password" } as any;
return cipher;
};
beforeEach(() => {
// Reset observables to default values for this test suite
featureFlagEnabled$.next(true);
hasPremiumFromAnySource$.next(true);
// Setup default mock for computeCipherRiskForUser (individual tests can override)
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue({
password_strength: 4,
exposed_result: { type: "NotFound" },
reuse_count: 1,
});
// Recreate the fixture for each test in this suite.
// This ensures that the signal's observable subscribes with the correct
// initial state
fixture = TestBed.createComponent(CipherViewComponent);
component = fixture.componentInstance;
});
it("returns false when feature flag is disabled", fakeAsync(() => {
featureFlagEnabled$.next(false);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when cipher has no login password", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.login = {} as any; // No password
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when user does not have edit access", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.edit = false;
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when cipher is deleted", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.deletedDate = new Date();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false for organization-owned ciphers", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.organizationId = "org-id";
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when user is not premium", fakeAsync(() => {
hasPremiumFromAnySource$.next(false);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns true when password is weak", fakeAsync(() => {
// Setup mock to return weak password
const mockRiskyResult = {
password_strength: 2, // Weak password (< 3)
exposed_result: { type: "NotFound" },
reuse_count: 1,
};
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockRiskyResult);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
// Initial value should be false (from startWith(false))
expect(component.passwordIsAtRisk()).toBe(false);
// Wait for async operations to complete
tick();
fixture.detectChanges();
// After async completes, should reflect the weak password
expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(true);
}));
it("returns false when password is strong and not exposed", fakeAsync(() => {
// Setup mock to return safe password
const mockSafeResult = {
password_strength: 4, // Strong password
exposed_result: { type: "NotFound" }, // Not exposed
reuse_count: 1, // Not reused
};
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockSafeResult);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
// Initial value should be false
expect(component.passwordIsAtRisk()).toBe(false);
// Wait for async operations to complete
tick();
fixture.detectChanges();
// Should remain false for safe password
expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
});
});

View File

@@ -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;
}
}
}

View File

@@ -90,13 +90,13 @@
data-testid="copy-password"
(click)="logCopyEvent()"
></button>
<bit-hint *ngIf="showChangePasswordLink">
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-hint>
</bit-form-field>
<bit-hint *ngIf="hadPendingChangePasswordTask">
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-hint>
<div
*ngIf="showPasswordCount && passwordRevealed"
[ngClass]="{ 'tw-mt-3': !cipher.login.totp, 'tw-mb-2': true }"

View File

@@ -71,7 +71,7 @@ export class LoginCredentialsViewComponent implements OnChanges {
@Input() activeUserId: UserId;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() hadPendingChangePasswordTask: boolean;
@Input() showChangePasswordLink: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() handleChangePassword = new EventEmitter<void>();