mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +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:
@@ -69,6 +69,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"noEditPermissions": {
|
||||||
|
"message": "You don't have permission to edit this item"
|
||||||
|
},
|
||||||
"welcomeBack": {
|
"welcomeBack": {
|
||||||
"message": "Welcome back"
|
"message": "Welcome back"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
|||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||||
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
|||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
|
import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
|
||||||
|
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
@@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => {
|
|||||||
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
||||||
},
|
},
|
||||||
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
||||||
|
{ provide: CipherRiskService, useValue: mock<CipherRiskService>() },
|
||||||
|
{
|
||||||
|
provide: BillingAccountProfileStateService,
|
||||||
|
useValue: mock<BillingAccountProfileStateService>(),
|
||||||
|
},
|
||||||
|
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideComponent(EmergencyViewDialogComponent, {
|
.overrideComponent(EmergencyViewDialogComponent, {
|
||||||
@@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => {
|
|||||||
provide: ChangeLoginPasswordService,
|
provide: ChangeLoginPasswordService,
|
||||||
useValue: ChangeLoginPasswordService,
|
useValue: ChangeLoginPasswordService,
|
||||||
},
|
},
|
||||||
{ provide: ConfigService, useValue: ConfigService },
|
|
||||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => {
|
|||||||
provide: ChangeLoginPasswordService,
|
provide: ChangeLoginPasswordService,
|
||||||
useValue: mock<ChangeLoginPasswordService>(),
|
useValue: mock<ChangeLoginPasswordService>(),
|
||||||
},
|
},
|
||||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
|
||||||
{ provide: CipherService, useValue: mock<CipherService>() },
|
{ provide: CipherService, useValue: mock<CipherService>() },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
"passwordRisk": {
|
"passwordRisk": {
|
||||||
"message": "Password Risk"
|
"message": "Password Risk"
|
||||||
},
|
},
|
||||||
|
"noEditPermissions": {
|
||||||
|
"message": "You don't have permission to edit this item"
|
||||||
|
},
|
||||||
"reviewAtRiskPasswords": {
|
"reviewAtRiskPasswords": {
|
||||||
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
|
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ import {
|
|||||||
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||||
|
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||||
@@ -303,6 +304,7 @@ import {
|
|||||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||||
|
import { DefaultCipherRiskService } from "@bitwarden/common/vault/services/default-cipher-risk.service";
|
||||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||||
@@ -605,6 +607,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
MessagingServiceAbstraction,
|
MessagingServiceAbstraction,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: CipherRiskService,
|
||||||
|
useClass: DefaultCipherRiskService,
|
||||||
|
deps: [SdkService, CipherServiceAbstraction],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: InternalFolderService,
|
provide: InternalFolderService,
|
||||||
useClass: FolderService,
|
useClass: FolderService,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export enum FeatureFlag {
|
|||||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||||
CipherKeyEncryption = "cipher-key-encryption",
|
CipherKeyEncryption = "cipher-key-encryption",
|
||||||
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
||||||
|
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||||
|
|
||||||
/* Platform */
|
/* Platform */
|
||||||
IpcChannelFramework = "ipc-channel-framework",
|
IpcChannelFramework = "ipc-channel-framework",
|
||||||
@@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||||
|
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
|
import { isPasswordAtRisk } from "./cipher-risk.service";
|
||||||
|
|
||||||
|
describe("isPasswordAtRisk", () => {
|
||||||
|
const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId;
|
||||||
|
|
||||||
|
const createRisk = (overrides: Partial<CipherRiskResult> = {}): CipherRiskResult => ({
|
||||||
|
id: mockId,
|
||||||
|
password_strength: 4,
|
||||||
|
exposed_result: { type: "NotChecked" },
|
||||||
|
reuse_count: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exposed password risk", () => {
|
||||||
|
it.each([
|
||||||
|
{ value: 5, expected: true, desc: "found with value > 0" },
|
||||||
|
{ value: 0, expected: false, desc: "found but value is 0" },
|
||||||
|
])("should return $expected when password is $desc", ({ value, expected }) => {
|
||||||
|
const risk = createRisk({ exposed_result: { type: "Found", value } });
|
||||||
|
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when password is not checked", () => {
|
||||||
|
expect(isPasswordAtRisk(createRisk())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password reuse risk", () => {
|
||||||
|
it.each([
|
||||||
|
{ count: 2, expected: true, desc: "reused (reuse_count > 1)" },
|
||||||
|
{ count: 1, expected: false, desc: "not reused" },
|
||||||
|
{ count: undefined, expected: false, desc: "undefined" },
|
||||||
|
])("should return $expected when reuse_count is $desc", ({ count, expected }) => {
|
||||||
|
const risk = createRisk({ reuse_count: count });
|
||||||
|
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password strength risk", () => {
|
||||||
|
it.each([
|
||||||
|
{ strength: 0, expected: true },
|
||||||
|
{ strength: 1, expected: true },
|
||||||
|
{ strength: 2, expected: true },
|
||||||
|
{ strength: 3, expected: false },
|
||||||
|
{ strength: 4, expected: false },
|
||||||
|
])("should return $expected when password strength is $strength", ({ strength, expected }) => {
|
||||||
|
const risk = createRisk({ password_strength: strength });
|
||||||
|
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple risk factors", () => {
|
||||||
|
it.each<{ desc: string; overrides: Partial<CipherRiskResult>; expected: boolean }>([
|
||||||
|
{
|
||||||
|
desc: "exposed and reused",
|
||||||
|
overrides: {
|
||||||
|
exposed_result: { type: "Found" as const, value: 3 },
|
||||||
|
reuse_count: 2,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "reused and weak strength",
|
||||||
|
overrides: { password_strength: 2, reuse_count: 2 },
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "all three risk factors",
|
||||||
|
overrides: {
|
||||||
|
password_strength: 1,
|
||||||
|
exposed_result: { type: "Found" as const, value: 10 },
|
||||||
|
reuse_count: 3,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "no risk factors",
|
||||||
|
overrides: { reuse_count: undefined },
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
])("should return $expected when $desc present", ({ overrides, expected }) => {
|
||||||
|
const risk = createRisk(overrides);
|
||||||
|
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
CipherRiskResult,
|
CipherRiskResult,
|
||||||
CipherRiskOptions,
|
CipherRiskOptions,
|
||||||
ExposedPasswordResult,
|
|
||||||
PasswordReuseMap,
|
PasswordReuseMap,
|
||||||
CipherId,
|
|
||||||
} from "@bitwarden/sdk-internal";
|
} from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId, CipherId } from "../../types/guid";
|
||||||
import { CipherView } from "../models/view/cipher.view";
|
import { CipherView } from "../models/view/cipher.view";
|
||||||
|
|
||||||
export abstract class CipherRiskService {
|
export abstract class CipherRiskService {
|
||||||
@@ -51,5 +49,21 @@ export abstract class CipherRiskService {
|
|||||||
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export SDK types for convenience
|
/**
|
||||||
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
|
* Evaluates if a password represented by a CipherRiskResult is considered at risk.
|
||||||
|
*
|
||||||
|
* A password is considered at risk if any of the following conditions are true:
|
||||||
|
* - The password has been exposed in data breaches
|
||||||
|
* - The password is reused across multiple ciphers
|
||||||
|
* - The password has weak strength (password_strength < 3)
|
||||||
|
*
|
||||||
|
* @param risk - The CipherRiskResult to evaluate
|
||||||
|
* @returns true if the password is at risk, false otherwise
|
||||||
|
*/
|
||||||
|
export function isPasswordAtRisk(risk: CipherRiskResult): boolean {
|
||||||
|
return (
|
||||||
|
(risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) ||
|
||||||
|
(risk.reuse_count ?? 1) > 1 ||
|
||||||
|
risk.password_strength < 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata {
|
|||||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasLoginPassword(): boolean {
|
||||||
|
return (
|
||||||
|
this.type === CipherType.Login && this.login?.password != null && this.login.password !== ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
get hasAttachments(): boolean {
|
get hasAttachments(): boolean {
|
||||||
return !!this.attachments && this.attachments.length > 0;
|
return !!this.attachments && this.attachments.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
|
||||||
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
|
import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||||
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId, CipherId } from "../../types/guid";
|
||||||
import { CipherService } from "../abstractions/cipher.service";
|
import { CipherService } from "../abstractions/cipher.service";
|
||||||
import { CipherType } from "../enums/cipher-type";
|
import { CipherType } from "../enums/cipher-type";
|
||||||
import { CipherView } from "../models/view/cipher.view";
|
import { CipherView } from "../models/view/cipher.view";
|
||||||
@@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => {
|
|||||||
let mockCipherService: jest.Mocked<CipherService>;
|
let mockCipherService: jest.Mocked<CipherService>;
|
||||||
|
|
||||||
const mockUserId = "test-user-id" as UserId;
|
const mockUserId = "test-user-id" as UserId;
|
||||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
|
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId;
|
||||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
|
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId;
|
||||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
|
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sdkService = new MockSdkService();
|
sdkService = new MockSdkService();
|
||||||
@@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => {
|
|||||||
// Verify password_reuse_map was called twice (fresh computation each time)
|
// Verify password_reuse_map was called twice (fresh computation each time)
|
||||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should wait for a decrypted vault before computing risk", async () => {
|
||||||
|
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||||
|
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||||
|
|
||||||
|
const cipher = new CipherView();
|
||||||
|
cipher.id = mockCipherId1;
|
||||||
|
cipher.type = CipherType.Login;
|
||||||
|
cipher.login = new LoginView();
|
||||||
|
cipher.login.password = "password1";
|
||||||
|
|
||||||
|
// Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers
|
||||||
|
const cipherViewsSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||||
|
mockCipherService.cipherViews$.mockReturnValue(
|
||||||
|
cipherViewsSubject as Observable<CipherView[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||||
|
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: mockCipherId1 as any,
|
||||||
|
password_strength: 4,
|
||||||
|
exposed_result: { type: "NotChecked" },
|
||||||
|
reuse_count: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Initiate the async call but don't await yet
|
||||||
|
const computePromise = cipherRiskService.computeCipherRiskForUser(
|
||||||
|
asUuid<CipherId>(mockCipherId1),
|
||||||
|
mockUserId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a tick to allow the service to process the null emission
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Now emit the actual decrypted ciphers
|
||||||
|
cipherViewsSubject.next([cipher]);
|
||||||
|
|
||||||
|
const result = await computePromise;
|
||||||
|
|
||||||
|
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({ password: "password1" })],
|
||||||
|
{
|
||||||
|
passwordMap: expect.any(Object),
|
||||||
|
checkExposed: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result).toEqual(expect.objectContaining({ id: expect.anything() }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { firstValueFrom, switchMap } from "rxjs";
|
import { firstValueFrom, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||||
import {
|
import {
|
||||||
CipherLoginDetails,
|
CipherLoginDetails,
|
||||||
CipherRiskOptions,
|
CipherRiskOptions,
|
||||||
PasswordReuseMap,
|
PasswordReuseMap,
|
||||||
CipherId,
|
|
||||||
CipherRiskResult,
|
CipherRiskResult,
|
||||||
|
CipherId as SdkCipherId,
|
||||||
} from "@bitwarden/sdk-internal";
|
} from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId, CipherId } from "../../types/guid";
|
||||||
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
||||||
import { CipherType } from "../enums/cipher-type";
|
import { CipherType } from "../enums/cipher-type";
|
||||||
import { CipherView } from "../models/view/cipher.view";
|
import { CipherView } from "../models/view/cipher.view";
|
||||||
@@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
|||||||
checkExposed: boolean = true,
|
checkExposed: boolean = true,
|
||||||
): Promise<CipherRiskResult> {
|
): Promise<CipherRiskResult> {
|
||||||
// Get all ciphers for the user
|
// Get all ciphers for the user
|
||||||
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
const allCiphers = await firstValueFrom(
|
||||||
|
this.cipherService.cipherViews$(userId).pipe(filterOutNullish()),
|
||||||
|
);
|
||||||
|
|
||||||
// Find the specific cipher
|
// Find the specific cipher
|
||||||
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
||||||
@@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
|||||||
.map(
|
.map(
|
||||||
(cipher) =>
|
(cipher) =>
|
||||||
({
|
({
|
||||||
id: asUuid<CipherId>(cipher.id),
|
id: asUuid<SdkCipherId>(cipher.id),
|
||||||
password: cipher.login.password!,
|
password: cipher.login.password!,
|
||||||
username: cipher.login.username,
|
username: cipher.login.username,
|
||||||
}) satisfies CipherLoginDetails,
|
}) satisfies CipherLoginDetails,
|
||||||
|
|||||||
@@ -1,89 +1,85 @@
|
|||||||
<ng-container *ngIf="!!cipher">
|
<ng-container *ngIf="!!cipher()">
|
||||||
<bit-callout *ngIf="cardIsExpired" type="info" [title]="'cardExpiredTitle' | i18n">
|
<bit-callout *ngIf="cardIsExpired()" type="info" [title]="'cardExpiredTitle' | i18n">
|
||||||
{{ "cardExpiredMessage" | i18n }}
|
{{ "cardExpiredMessage" | i18n }}
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
|
|
||||||
<bit-callout
|
<bit-callout
|
||||||
*ngIf="!hasLoginUri && hadPendingChangePasswordTask"
|
*ngIf="!hasLoginUri() && hadPendingChangePasswordTask()"
|
||||||
type="warning"
|
type="warning"
|
||||||
[title]="'missingWebsite' | i18n"
|
[title]="'missingWebsite' | i18n"
|
||||||
>
|
>
|
||||||
{{ "changeAtRiskPasswordAndAddWebsite" | i18n }}
|
{{ "changeAtRiskPasswordAndAddWebsite" | i18n }}
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
|
|
||||||
<bit-callout *ngIf="hasLoginUri && hadPendingChangePasswordTask" type="warning" [title]="''">
|
<bit-callout *ngIf="showChangePasswordLink()" type="warning" [title]="''">
|
||||||
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
|
<a bitLink href="#" appStopClick (click)="launchChangePassword()" linkType="secondary">
|
||||||
{{ "changeAtRiskPassword" | i18n }}
|
{{ "changeAtRiskPassword" | i18n }}
|
||||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</bit-callout>
|
</bit-callout>
|
||||||
|
|
||||||
<!-- HELPER TEXT -->
|
<!-- HELPER TEXT -->
|
||||||
<p
|
<p class="tw-text-muted" bitTypography="helper" *ngIf="cipher()?.isDeleted && !cipher()?.edit">
|
||||||
class="tw-text-sm tw-text-muted"
|
|
||||||
bitTypography="helper"
|
|
||||||
*ngIf="cipher?.isDeleted && !cipher?.edit"
|
|
||||||
>
|
|
||||||
{{ "noEditPermissions" | i18n }}
|
{{ "noEditPermissions" | i18n }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- ITEM DETAILS -->
|
<!-- ITEM DETAILS -->
|
||||||
<app-item-details-v2
|
<app-item-details-v2
|
||||||
[cipher]="cipher"
|
[cipher]="cipher()"
|
||||||
[organization]="organization$ | async"
|
[organization]="organization()"
|
||||||
[collections]="collections"
|
[collections]="resolvedCollections()"
|
||||||
[folder]="folder$ | async"
|
[folder]="folder()"
|
||||||
[hideOwner]="isAdminConsole"
|
[hideOwner]="isAdminConsole()"
|
||||||
>
|
>
|
||||||
</app-item-details-v2>
|
</app-item-details-v2>
|
||||||
|
|
||||||
<!-- LOGIN CREDENTIALS -->
|
<!-- LOGIN CREDENTIALS -->
|
||||||
<app-login-credentials-view
|
<app-login-credentials-view
|
||||||
*ngIf="hasLogin"
|
*ngIf="hasLogin()"
|
||||||
[cipher]="cipher"
|
[cipher]="cipher()"
|
||||||
[activeUserId]="activeUserId$ | async"
|
[activeUserId]="activeUserId$ | async"
|
||||||
[hadPendingChangePasswordTask]="hadPendingChangePasswordTask && cipher?.login.uris.length > 0"
|
[showChangePasswordLink]="showChangePasswordLink()"
|
||||||
(handleChangePassword)="launchChangePassword()"
|
(handleChangePassword)="launchChangePassword()"
|
||||||
></app-login-credentials-view>
|
></app-login-credentials-view>
|
||||||
|
|
||||||
<!-- AUTOFILL OPTIONS -->
|
<!-- AUTOFILL OPTIONS -->
|
||||||
<app-autofill-options-view
|
<app-autofill-options-view
|
||||||
*ngIf="hasAutofill"
|
*ngIf="hasAutofill()"
|
||||||
[loginUris]="cipher.login.uris"
|
[loginUris]="cipher()!.login.uris"
|
||||||
[cipherId]="cipher.id"
|
[cipherId]="cipher()!.id"
|
||||||
>
|
>
|
||||||
</app-autofill-options-view>
|
</app-autofill-options-view>
|
||||||
|
|
||||||
<!-- CARD DETAILS -->
|
<!-- 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 -->
|
<!-- IDENTITY SECTIONS -->
|
||||||
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
|
<app-view-identity-sections *ngIf="cipher()?.identity" [cipher]="cipher()">
|
||||||
</app-view-identity-sections>
|
</app-view-identity-sections>
|
||||||
|
|
||||||
<!-- SshKEY 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 -->
|
<!-- ADDITIONAL OPTIONS -->
|
||||||
<ng-container *ngIf="cipher.notes">
|
<ng-container *ngIf="cipher()?.notes">
|
||||||
<app-additional-options [notes]="cipher.notes"> </app-additional-options>
|
<app-additional-options [notes]="cipher()!.notes"> </app-additional-options>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- CUSTOM FIELDS -->
|
<!-- CUSTOM FIELDS -->
|
||||||
<ng-container *ngIf="cipher.hasFields">
|
<ng-container *ngIf="cipher()?.hasFields">
|
||||||
<app-custom-fields-v2 [cipher]="cipher"> </app-custom-fields-v2>
|
<app-custom-fields-v2 [cipher]="cipher()"> </app-custom-fields-v2>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- ATTACHMENTS SECTION -->
|
<!-- ATTACHMENTS SECTION -->
|
||||||
<ng-container *ngIf="cipher.hasAttachments">
|
<ng-container *ngIf="cipher()?.hasAttachments">
|
||||||
<app-attachments-v2-view
|
<app-attachments-v2-view
|
||||||
[emergencyAccessId]="emergencyAccessId"
|
[emergencyAccessId]="emergencyAccessId()"
|
||||||
[cipher]="cipher"
|
[cipher]="cipher()"
|
||||||
[admin]="isAdminConsole"
|
[admin]="isAdminConsole()"
|
||||||
>
|
>
|
||||||
</app-attachments-v2-view>
|
</app-attachments-v2-view>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- ITEM HISTORY SECTION -->
|
<!-- 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>
|
</ng-container>
|
||||||
|
|||||||
287
libs/vault/src/cipher-view/cipher-view.component.spec.ts
Normal file
287
libs/vault/src/cipher-view/cipher-view.component.spec.ts
Normal 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);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,38 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, Input, OnChanges, OnDestroy } from "@angular/core";
|
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||||
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
|
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.
|
// 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
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import {
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
getOrganizationById,
|
|
||||||
OrganizationService,
|
|
||||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { getByIds } from "@bitwarden/common/platform/misc";
|
import { getByIds } from "@bitwarden/common/platform/misc";
|
||||||
import { CipherId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
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 { 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";
|
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 { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component";
|
||||||
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.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({
|
@Component({
|
||||||
selector: "app-cipher-view",
|
selector: "app-cipher-view",
|
||||||
templateUrl: "cipher-view.component.html",
|
templateUrl: "cipher-view.component.html",
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
CalloutModule,
|
CalloutModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -60,38 +67,37 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
|||||||
LoginCredentialsViewComponent,
|
LoginCredentialsViewComponent,
|
||||||
AutofillOptionsViewComponent,
|
AutofillOptionsViewComponent,
|
||||||
AnchorLinkDirective,
|
AnchorLinkDirective,
|
||||||
|
TypographyModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CipherViewComponent implements OnChanges, OnDestroy {
|
export class CipherViewComponent {
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
/**
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
* The cipher to display details for
|
||||||
@Input({ required: true }) cipher: CipherView | null = null;
|
*/
|
||||||
|
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
|
* Observable version of the cipher input
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
*/
|
||||||
@Input() emergencyAccessId?: EmergencyAccessId;
|
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
|
* 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.
|
* `CipherService` and the `collectionIds` property of the cipher.
|
||||||
*/
|
*/
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
readonly collections = input<CollectionView[] | undefined>(undefined);
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
||||||
@Input() collections?: CollectionView[];
|
|
||||||
|
|
||||||
/** 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
|
* Should be set to true when the component is used within the Admin Console
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
*/
|
||||||
@Input() isAdminConsole?: boolean = false;
|
readonly isAdminConsole = input<boolean>(false);
|
||||||
|
|
||||||
organization$: Observable<Organization | undefined> | undefined;
|
readonly activeUserId$ = getUserId(this.accountService.activeAccount$);
|
||||||
folder$: Observable<FolderView | undefined> | undefined;
|
|
||||||
private destroyed$: Subject<void> = new Subject();
|
|
||||||
cardIsExpired: boolean = false;
|
|
||||||
hadPendingChangePasswordTask: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
@@ -103,126 +109,206 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
|||||||
private changeLoginPasswordService: ChangeLoginPasswordService,
|
private changeLoginPasswordService: ChangeLoginPasswordService,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
|
private cipherRiskService: CipherRiskService,
|
||||||
|
private billingAccountService: BillingAccountProfileStateService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnChanges() {
|
readonly resolvedCollections = toSignal<CollectionView[] | undefined>(
|
||||||
if (this.cipher == null) {
|
combineLatest([this.activeUserId$, this.cipher$, toObservable(this.collections)]).pipe(
|
||||||
return;
|
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 {
|
return combineLatest([
|
||||||
this.destroyed$.next();
|
this.cipherService.ciphers$(userId),
|
||||||
this.destroyed$.complete();
|
this.defaultTaskService.pendingTasks$(userId),
|
||||||
}
|
]).pipe(
|
||||||
|
map(([allCiphers, tasks]) => {
|
||||||
|
const cipherServiceCipher = allCiphers[cipher?.id as CipherId];
|
||||||
|
|
||||||
get hasCard() {
|
// Show tasks only for Manage and Edit permissions
|
||||||
if (!this.cipher) {
|
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;
|
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;
|
return cardholderName || code || expMonth || expYear || number;
|
||||||
}
|
});
|
||||||
|
|
||||||
get hasLogin() {
|
readonly cardIsExpired = computed(() => {
|
||||||
if (!this.cipher) {
|
const cipher = this.cipher();
|
||||||
|
if (cipher == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isCardExpired(cipher.card);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly hasLogin = computed(() => {
|
||||||
|
const cipher = this.cipher();
|
||||||
|
if (!cipher) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, totp, fido2Credentials } = this.cipher.login;
|
const { username, password, totp, fido2Credentials } = cipher.login;
|
||||||
|
|
||||||
return username || password || totp || fido2Credentials?.length > 0;
|
return username || password || totp || fido2Credentials?.length > 0;
|
||||||
}
|
});
|
||||||
|
|
||||||
get hasAutofill() {
|
readonly hasAutofill = computed(() => {
|
||||||
const uris = this.cipher?.login?.uris.length ?? 0;
|
const cipher = this.cipher();
|
||||||
|
const uris = cipher?.login?.uris.length ?? 0;
|
||||||
|
|
||||||
return uris > 0;
|
return uris > 0;
|
||||||
}
|
});
|
||||||
|
|
||||||
get hasSshKey() {
|
readonly hasSshKey = computed(() => {
|
||||||
return !!this.cipher?.sshKey?.privateKey;
|
const cipher = this.cipher();
|
||||||
}
|
return !!cipher?.sshKey?.privateKey;
|
||||||
|
});
|
||||||
|
|
||||||
get hasLoginUri() {
|
readonly hasLoginUri = computed(() => {
|
||||||
return this.cipher?.login?.hasUris;
|
const cipher = this.cipher();
|
||||||
}
|
return cipher?.login?.hasUris;
|
||||||
|
});
|
||||||
|
|
||||||
async loadCipherData() {
|
/**
|
||||||
if (!this.cipher) {
|
* Whether the login password for the cipher is considered at risk.
|
||||||
return;
|
* The password is only evaluated when the user is premium and has edit access to the cipher.
|
||||||
}
|
*/
|
||||||
|
readonly passwordIsAtRisk = toSignal(
|
||||||
const userId = await firstValueFrom(this.activeUserId$);
|
combineLatest([
|
||||||
|
this.activeUserId$,
|
||||||
// Load collections if not provided and the cipher has collectionIds
|
this.cipher$,
|
||||||
if (
|
this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium),
|
||||||
this.cipher.collectionIds &&
|
]).pipe(
|
||||||
this.cipher.collectionIds.length > 0 &&
|
switchMap(([userId, cipher, featureEnabled]) => {
|
||||||
(!this.collections || this.collections.length === 0)
|
if (
|
||||||
) {
|
!featureEnabled ||
|
||||||
this.collections = await firstValueFrom(
|
!cipher.hasLoginPassword ||
|
||||||
this.collectionService
|
!cipher.edit ||
|
||||||
.decryptedCollections$(userId)
|
cipher.organizationId ||
|
||||||
.pipe(getByIds(this.cipher.collectionIds)),
|
cipher.isDeleted
|
||||||
);
|
) {
|
||||||
}
|
return of(false);
|
||||||
|
}
|
||||||
if (this.cipher.organizationId) {
|
return this.switchPremium$(
|
||||||
this.organization$ = this.organizationService
|
userId,
|
||||||
.organizations$(userId)
|
() =>
|
||||||
.pipe(getOrganizationById(this.cipher.organizationId))
|
from(this.checkIfPasswordIsAtRisk(cipher.id as CipherId, userId as UserId)).pipe(
|
||||||
.pipe(takeUntil(this.destroyed$));
|
startWith(false),
|
||||||
|
),
|
||||||
if (this.cipher.type === CipherType.Login) {
|
() => of(false),
|
||||||
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
|
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
} catch (error) {
|
),
|
||||||
this.hadPendingChangePasswordTask = false;
|
{ initialValue: false },
|
||||||
this.logService.error("Failed to retrieve change password tasks for cipher", error);
|
);
|
||||||
}
|
|
||||||
}
|
readonly showChangePasswordLink = computed(() => {
|
||||||
|
return this.hasLoginUri() && (this.hadPendingChangePasswordTask() || this.passwordIsAtRisk());
|
||||||
|
});
|
||||||
|
|
||||||
launchChangePassword = async () => {
|
launchChangePassword = async () => {
|
||||||
if (this.cipher != null) {
|
const cipher = this.cipher();
|
||||||
const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher);
|
if (cipher != null) {
|
||||||
|
const url = await this.changeLoginPasswordService.getChangePasswordUrl(cipher);
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.platformUtilsService.launchUri(url);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,13 +90,13 @@
|
|||||||
data-testid="copy-password"
|
data-testid="copy-password"
|
||||||
(click)="logCopyEvent()"
|
(click)="logCopyEvent()"
|
||||||
></button>
|
></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-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
|
<div
|
||||||
*ngIf="showPasswordCount && passwordRevealed"
|
*ngIf="showPasswordCount && passwordRevealed"
|
||||||
[ngClass]="{ 'tw-mt-3': !cipher.login.totp, 'tw-mb-2': true }"
|
[ngClass]="{ 'tw-mt-3': !cipher.login.totp, 'tw-mb-2': true }"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export class LoginCredentialsViewComponent implements OnChanges {
|
|||||||
@Input() activeUserId: UserId;
|
@Input() activeUserId: UserId;
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-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
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||||
@Output() handleChangePassword = new EventEmitter<void>();
|
@Output() handleChangePassword = new EventEmitter<void>();
|
||||||
|
|||||||
Reference in New Issue
Block a user