1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-27024] password progress card at risk detection (#16955)

* [PM-27024] Fix password change progress card to track only critical apps and detect new at-risk passwords
- Filter at-risk password count to critical applications only
- Update state logic to transition back to assign tasks when new at-risk passwords detected
- Only create security tasks for critical applications with at-risk passwords
- Show 'X new passwords at-risk' message when tasks exist and new at-risk passwords appear

* spec
This commit is contained in:
Alex
2025-10-21 11:02:44 -04:00
committed by GitHub
parent f1340c67da
commit a5dd42396c
6 changed files with 87 additions and 23 deletions

View File

@@ -154,6 +154,15 @@
}
}
},
"newPasswordsAtRisk": {
"message": "$COUNT$ new passwords at-risk",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"notifiedMembersWithCount": {
"message": "Notified members ($COUNT$)",
"placeholders": {

View File

@@ -76,7 +76,9 @@ export class AllActivitiesService {
}
setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) {
const totalAtRiskPasswords = applications.reduce(
// Only count at-risk passwords for CRITICAL applications
const criticalApps = applications.filter((app) => app.isMarkedAsCritical);
const totalAtRiskPasswords = criticalApps.reduce(
(sum, app) => sum + app.atRiskPasswordCount,
0,
);

View File

@@ -21,7 +21,11 @@
</div>
<div class="tw-items-baseline tw-gap-2">
<span bitTypography="body2">{{ "countOfAtRiskPasswords" | i18n: atRiskPasswordsCount }}</span>
<span bitTypography="body2">{{
hasExistingTasks
? ("newPasswordsAtRisk" | i18n: newAtRiskPasswordsCount)
: ("countOfAtRiskPasswords" | i18n: atRiskPasswordsCount)
}}</span>
</div>
<div class="tw-mt-4">

View File

@@ -9,6 +9,7 @@ import {
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
SecurityTasksApiService,
TaskMetrics,
OrganizationReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
@@ -73,25 +74,8 @@ export class PasswordChangeMetricComponent implements OnInit {
this.totalTasks = taskMetrics.totalTasks;
this.allApplicationsDetails = allApplicationsDetails;
// No critical apps setup
this.renderMode =
summary.totalCriticalApplicationCount === 0 ? RenderMode.noCriticalApps : this.renderMode;
// Critical apps setup with at-risk apps but no tasks
this.renderMode =
summary.totalCriticalApplicationCount > 0 &&
summary.totalCriticalAtRiskApplicationCount >= 0 &&
taskMetrics.totalTasks === 0
? RenderMode.criticalAppsWithAtRiskAppsAndNoTasks
: this.renderMode;
// Critical apps setup with at-risk apps and tasks
this.renderMode =
summary.totalAtRiskApplicationCount > 0 &&
summary.totalCriticalAtRiskApplicationCount >= 0 &&
taskMetrics.totalTasks > 0
? RenderMode.criticalAppsWithAtRiskAppsAndTasks
: this.renderMode;
// Determine render mode based on state
this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount);
this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar(
this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks,
@@ -106,6 +90,38 @@ export class PasswordChangeMetricComponent implements OnInit {
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService,
) {}
private determineRenderMode(
summary: OrganizationReportSummary,
taskMetrics: TaskMetrics,
atRiskPasswordsCount: number,
): RenderMode {
// State 1: No critical apps setup
if (summary.totalCriticalApplicationCount === 0) {
return RenderMode.noCriticalApps;
}
// State 2: Critical apps with at-risk passwords but no tasks assigned yet
// OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks)
if (
summary.totalCriticalApplicationCount > 0 &&
(taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks)
) {
return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks;
}
// State 3: Critical apps with at-risk apps and tasks (progress tracking)
if (
summary.totalCriticalApplicationCount > 0 &&
taskMetrics.totalTasks > 0 &&
atRiskPasswordsCount <= taskMetrics.totalTasks
) {
return RenderMode.criticalAppsWithAtRiskAppsAndTasks;
}
// Default to no critical apps
return RenderMode.noCriticalApps;
}
get completedPercent(): number {
if (this.totalTasks === 0) {
return 0;
@@ -144,7 +160,19 @@ export class PasswordChangeMetricComponent implements OnInit {
}
get canAssignTasks(): boolean {
return this.atRiskAppsCount > this.totalTasks ? true : false;
return this.atRiskPasswordsCount > this.totalTasks;
}
get hasExistingTasks(): boolean {
return this.totalTasks > 0;
}
get newAtRiskPasswordsCount(): number {
// Calculate new at-risk passwords as the difference between current count and tasks created
if (this.atRiskPasswordsCount > this.totalTasks) {
return this.atRiskPasswordsCount - this.totalTasks;
}
return 0;
}
get renderModes() {

View File

@@ -40,6 +40,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-1" as OrganizationId;
const apps = [
{
isMarkedAsCritical: true,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid1"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -56,10 +57,12 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-2" as OrganizationId;
const apps = [
{
isMarkedAsCritical: true,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cid1", "cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
{
isMarkedAsCritical: true,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -85,6 +88,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-3" as OrganizationId;
const apps = [
{
isMarkedAsCritical: true,
atRiskPasswordCount: 1,
atRiskCipherIds: ["cid3"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -106,6 +110,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-4" as OrganizationId;
const apps = [
{
isMarkedAsCritical: true,
atRiskPasswordCount: 0,
atRiskCipherIds: ["cid4"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -115,5 +120,20 @@ describe("AccessIntelligenceSecurityTasksService", () => {
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0);
});
it("should not create any tasks for non-critical apps", async () => {
const organizationId = "org-5" as OrganizationId;
const apps = [
{
isMarkedAsCritical: false,
atRiskPasswordCount: 2,
atRiskCipherIds: ["cid5", "cid6"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
];
const result = await service.requestPasswordChange(organizationId, apps);
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0);
});
});
});

View File

@@ -33,8 +33,9 @@ export class AccessIntelligenceSecurityTasksService {
organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
): Promise<number> {
// Only create tasks for CRITICAL applications with at-risk passwords
const cipherIds = apps
.filter((_) => _.atRiskPasswordCount > 0)
.filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0)
.flatMap((app) => app.atRiskCipherIds);
const distinctCipherIds = Array.from(new Set(cipherIds));