1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +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": { "notifiedMembersWithCount": {
"message": "Notified members ($COUNT$)", "message": "Notified members ($COUNT$)",
"placeholders": { "placeholders": {

View File

@@ -76,7 +76,9 @@ export class AllActivitiesService {
} }
setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) { 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, (sum, app) => sum + app.atRiskPasswordCount,
0, 0,
); );

View File

@@ -21,7 +21,11 @@
</div> </div>
<div class="tw-items-baseline tw-gap-2"> <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>
<div class="tw-mt-4"> <div class="tw-mt-4">

View File

@@ -9,6 +9,7 @@ import {
LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
SecurityTasksApiService, SecurityTasksApiService,
TaskMetrics, TaskMetrics,
OrganizationReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights"; } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components";
@@ -73,25 +74,8 @@ export class PasswordChangeMetricComponent implements OnInit {
this.totalTasks = taskMetrics.totalTasks; this.totalTasks = taskMetrics.totalTasks;
this.allApplicationsDetails = allApplicationsDetails; this.allApplicationsDetails = allApplicationsDetails;
// No critical apps setup // Determine render mode based on state
this.renderMode = this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount);
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;
this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar(
this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks,
@@ -106,6 +90,38 @@ export class PasswordChangeMetricComponent implements OnInit {
protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, 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 { get completedPercent(): number {
if (this.totalTasks === 0) { if (this.totalTasks === 0) {
return 0; return 0;
@@ -144,7 +160,19 @@ export class PasswordChangeMetricComponent implements OnInit {
} }
get canAssignTasks(): boolean { 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() { get renderModes() {

View File

@@ -40,6 +40,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-1" as OrganizationId; const organizationId = "org-1" as OrganizationId;
const apps = [ const apps = [
{ {
isMarkedAsCritical: true,
atRiskPasswordCount: 1, atRiskPasswordCount: 1,
atRiskCipherIds: ["cid1"], atRiskCipherIds: ["cid1"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -56,10 +57,12 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-2" as OrganizationId; const organizationId = "org-2" as OrganizationId;
const apps = [ const apps = [
{ {
isMarkedAsCritical: true,
atRiskPasswordCount: 2, atRiskPasswordCount: 2,
atRiskCipherIds: ["cid1", "cid2"], atRiskCipherIds: ["cid1", "cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
{ {
isMarkedAsCritical: true,
atRiskPasswordCount: 1, atRiskPasswordCount: 1,
atRiskCipherIds: ["cid2"], atRiskCipherIds: ["cid2"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -85,6 +88,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-3" as OrganizationId; const organizationId = "org-3" as OrganizationId;
const apps = [ const apps = [
{ {
isMarkedAsCritical: true,
atRiskPasswordCount: 1, atRiskPasswordCount: 1,
atRiskCipherIds: ["cid3"], atRiskCipherIds: ["cid3"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -106,6 +110,7 @@ describe("AccessIntelligenceSecurityTasksService", () => {
const organizationId = "org-4" as OrganizationId; const organizationId = "org-4" as OrganizationId;
const apps = [ const apps = [
{ {
isMarkedAsCritical: true,
atRiskPasswordCount: 0, atRiskPasswordCount: 0,
atRiskCipherIds: ["cid4"], atRiskCipherIds: ["cid4"],
} as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher,
@@ -115,5 +120,20 @@ describe("AccessIntelligenceSecurityTasksService", () => {
expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []);
expect(result).toBe(0); 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, organizationId: OrganizationId,
apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[],
): Promise<number> { ): Promise<number> {
// Only create tasks for CRITICAL applications with at-risk passwords
const cipherIds = apps const cipherIds = apps
.filter((_) => _.atRiskPasswordCount > 0) .filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0)
.flatMap((app) => app.atRiskCipherIds); .flatMap((app) => app.atRiskCipherIds);
const distinctCipherIds = Array.from(new Set(cipherIds)); const distinctCipherIds = Array.from(new Set(cipherIds));