1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 04:03:29 +00:00

Update some components to use the new risk-insights or application signal stores

This commit is contained in:
Leslie Tilton
2025-06-13 09:23:41 -05:00
parent 8515f0dfcc
commit b194cac82a
32 changed files with 1238 additions and 847 deletions

View File

@@ -36,7 +36,7 @@ export class CriticalAppsService {
private fetchOrg$ = this.orgId
.pipe(
switchMap((orgId) => this.retrieveCriticalApps(orgId)),
switchMap((orgId) => this._retrieveCriticalApps(orgId)),
takeUntil(this.teardown),
)
.subscribe((apps) => this.criticalAppsList.next(apps));
@@ -48,7 +48,7 @@ export class CriticalAppsService {
) {}
// Get a list of critical apps for a given organization
getAppsListForOrg(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
generateAppsListForOrg$(orgId: string): Observable<PasswordHealthReportApplicationsResponse[]> {
return this.criticalAppsList
.asObservable()
.pipe(map((apps) => apps.filter((app) => app.organizationId === orgId)));
@@ -67,8 +67,8 @@ export class CriticalAppsService {
}
// only save records that are not already in the database
const newEntries = await this.filterNewEntries(orgId as OrganizationId, selectedUrls);
const criticalAppsRequests = await this.encryptNewEntries(
const newEntries = await this._filterNewEntries(orgId as OrganizationId, selectedUrls);
const criticalAppsRequests = await this._encryptNewEntries(
orgId as OrganizationId,
key,
newEntries,
@@ -94,6 +94,7 @@ export class CriticalAppsService {
}
}
this.criticalAppsList.next(updatedList);
return updatedList;
}
// Get the critical apps for a given organization
@@ -120,7 +121,7 @@ export class CriticalAppsService {
this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl));
}
private retrieveCriticalApps(
private _retrieveCriticalApps(
orgId: OrganizationId | null,
): Observable<PasswordHealthReportApplicationsResponse[]> {
if (orgId === null) {
@@ -149,7 +150,10 @@ export class CriticalAppsService {
return result$ as Observable<PasswordHealthReportApplicationsResponse[]>;
}
private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise<string[]> {
private async _filterNewEntries(
orgId: OrganizationId,
selectedUrls: string[],
): Promise<string[]> {
return await firstValueFrom(this.criticalAppsList).then((criticalApps) => {
const criticalAppsUri = criticalApps
.filter((f) => f.organizationId === orgId)
@@ -158,7 +162,7 @@ export class CriticalAppsService {
});
}
private async encryptNewEntries(
private async _encryptNewEntries(
orgId: OrganizationId,
key: OrgKey,
newEntries: string[],

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe
// @ts-strict-ignore
import { concatMap, first, from, map, Observable, zip } from "rxjs";
import { BehaviorSubject, concatMap, first, from, map, mergeMap, Observable, zip } from "rxjs";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -24,7 +24,21 @@ import {
import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service";
/**
* Service responsible for handling business logic and data transformation
* related to risk insights reports. It collects, processes, and converts
* raw data into structured domain models such as health reports, summaries,
* and lists of at-risk items.
*/
export class RiskInsightsReportService {
// Renamed to clarify this is the full report, not just "applications"
private _applicationReportsSubject = new BehaviorSubject<ApplicationHealthReportDetail[]>([]);
/**
* Observable exposing the full application health reports.
*/
applicationReports$ = this._applicationReportsSubject.asObservable();
constructor(
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private auditService: AuditService,
@@ -32,6 +46,25 @@ export class RiskInsightsReportService {
private memberCipherDetailsApiService: MemberCipherDetailsApiService,
) {}
/**
* Fetches the applications report and updates the applicationReportsSubject.
* @param organizationId The ID of the organization.
*/
loadApplicationsReport(organizationId: string): void {
this.generateApplicationsReport$(organizationId).subscribe({
next: (reports: ApplicationHealthReportDetail[]) => {
this._applicationReportsSubject.next(reports);
},
error: () => {
this._applicationReportsSubject.next([]);
},
});
}
refreshApplicationsReport(organizationId: string): void {
this.loadApplicationsReport(organizationId);
}
/**
* Report data from raw cipher health data.
* Can be used in the Raw Data diagnostic tab (just exclude the members in the view)
@@ -49,28 +82,12 @@ export class RiskInsightsReportService {
map(([allCiphers, memberCiphers]) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) =>
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
this._getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
),
);
return [allCiphers, details] as const;
}),
concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)),
first(),
);
return results$;
}
/**
* Report data for raw cipher health broken out into the uris
* Can be used in the raw data + members + uri diagnostic report
* @param organizationId Id of the organization
* @returns Cipher health report data flattened to the uris
*/
generateRawDataUriReport$(organizationId: string): Observable<CipherHealthReportUriDetail[]> {
const cipherHealthDetails$ = this.generateRawDataReport$(organizationId);
const results$ = cipherHealthDetails$.pipe(
map((healthDetails) => this.getCipherUriDetails(healthDetails)),
concatMap(([ciphers, flattenedDetails]) => this._getCipherDetails(ciphers, flattenedDetails)),
first(),
);
@@ -84,13 +101,56 @@ export class RiskInsightsReportService {
* @returns The all applications health report data
*/
generateApplicationsReport$(organizationId: string): Observable<ApplicationHealthReportDetail[]> {
const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId);
const results$ = cipherHealthUriReport$.pipe(
map((uriDetails) => this.getApplicationHealthReport(uriDetails)),
// Compose the observable pipeline directly for clarity and efficiency
return from(this.cipherService.getAllFromApiForOrganization(organizationId)).pipe(
mergeMap((allCiphers) =>
from(this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId)).pipe(
mergeMap((memberCiphers) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) =>
this._getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
),
);
return from(this._getCipherDetails(allCiphers, details));
}),
),
),
map((cipherHealthDetails) =>
cipherHealthDetails.flatMap((rpt) =>
rpt.trimmedUris.map((u) => this._getFlattenedCipherDetails(rpt, u)),
),
),
map((cipherHealthUriReport) =>
this._aggregateApplicationHealthReports(cipherHealthUriReport),
),
first(),
);
}
return results$;
/**
* Aggregates cipher health URI details into application health report details.
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private _aggregateApplicationHealthReports(
cipherHealthUriReport: CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
cipherHealthUriReport.forEach((uri) => {
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
let atRisk: boolean = false;
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
atRisk = true;
}
if (index === -1) {
appReports.push(this._getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this._getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
/**
@@ -151,10 +211,10 @@ export class RiskInsightsReportService {
reports: ApplicationHealthReportDetail[],
): ApplicationHealthReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = this.getUniqueMembers(totalMembers);
const uniqueMembers = this._getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
const uniqueAtRiskMembers = this._getUniqueMembers(atRiskMembers);
return {
totalMemberCount: uniqueMembers.length,
@@ -171,16 +231,16 @@ export class RiskInsightsReportService {
* @param memberDetails Org members
* @returns Cipher password health data with trimmed uris and associated members
*/
private async getCipherDetails(
private async _getCipherDetails(
ciphers: CipherView[],
memberDetails: MemberDetailsFlat[],
): Promise<CipherHealthReportDetail[]> {
const cipherHealthReports: CipherHealthReportDetail[] = [];
const passwordUseMap = new Map<string, number>();
const exposedDetails = await this.findExposedPasswords(ciphers);
const exposedDetails = await this._findExposedPasswords(ciphers);
for (const cipher of ciphers) {
if (this.validateCipher(cipher)) {
const weakPassword = this.findWeakPassword(cipher);
if (this._validateCipher(cipher)) {
const weakPassword = this._findWeakPassword(cipher);
// Looping over all ciphers needs to happen first to determine reused passwords over all ciphers.
// Store in the set and evaluate later
if (passwordUseMap.has(cipher.login.password)) {
@@ -198,7 +258,7 @@ export class RiskInsightsReportService {
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
// Trim uris to host name and create the cipher health report
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
const cipherTrimmedUris = this._getTrimmedCipherUris(cipher);
const cipherHealth = {
...cipher,
weakPasswordDetail: weakPassword,
@@ -223,47 +283,20 @@ export class RiskInsightsReportService {
* @param cipherHealthReport Cipher health report with uris and members
* @returns Flattened cipher health details to uri
*/
private getCipherUriDetails(
private _getCipherUriDetails(
cipherHealthReport: CipherHealthReportDetail[],
): CipherHealthReportUriDetail[] {
return cipherHealthReport.flatMap((rpt) =>
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
rpt.trimmedUris.map((u) => this._getFlattenedCipherDetails(rpt, u)),
);
}
/**
* Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item.
* If the item is new, create and add the object with the flattened details
* @param cipherHealthUriReport Cipher and password health info broken out into their uris
* @returns Application health reports
*/
private getApplicationHealthReport(
cipherHealthUriReport: CipherHealthReportUriDetail[],
): ApplicationHealthReportDetail[] {
const appReports: ApplicationHealthReportDetail[] = [];
cipherHealthUriReport.forEach((uri) => {
const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri);
let atRisk: boolean = false;
if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) {
atRisk = true;
}
if (index === -1) {
appReports.push(this.getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
}
private async findExposedPasswords(ciphers: CipherView[]): Promise<ExposedPasswordDetail[]> {
private async _findExposedPasswords(ciphers: CipherView[]): Promise<ExposedPasswordDetail[]> {
const exposedDetails: ExposedPasswordDetail[] = [];
const promises: Promise<void>[] = [];
ciphers.forEach((ciph) => {
if (this.validateCipher(ciph)) {
if (this._validateCipher(ciph)) {
const promise = this.auditService
.passwordLeaked(ciph.login.password)
.then((exposedCount) => {
@@ -283,8 +316,8 @@ export class RiskInsightsReportService {
return exposedDetails;
}
private findWeakPassword(cipher: CipherView): WeakPasswordDetail {
const hasUserName = this.isUserNameNotEmpty(cipher);
private _findWeakPassword(cipher: CipherView): WeakPasswordDetail {
const hasUserName = this._isUserNameNotEmpty(cipher);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = cipher.login.username.indexOf("@");
@@ -313,14 +346,14 @@ export class RiskInsightsReportService {
);
if (score != null && score <= 2) {
const scoreValue = this.weakPasswordScore(score);
const scoreValue = this._weakPasswordScore(score);
const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail;
return weakPasswordDetail;
}
return null;
}
private weakPasswordScore(score: number): WeakPasswordScore {
private _weakPasswordScore(score: number): WeakPasswordScore {
switch (score) {
case 4:
return { label: "strong", badgeVariant: "success" };
@@ -341,7 +374,7 @@ export class RiskInsightsReportService {
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
private getApplicationReportDetail(
private _getApplicationReportDetail(
newUriDetail: CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
@@ -352,7 +385,7 @@ export class RiskInsightsReportService {
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
? this._getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
@@ -365,7 +398,7 @@ export class RiskInsightsReportService {
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
reportDetail.atRiskMemberDetails = this.getUniqueMembers(
reportDetail.atRiskMemberDetails = this._getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
);
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
@@ -382,7 +415,7 @@ export class RiskInsightsReportService {
* @param orgMembers Input list of members
* @returns Distinct array of members
*/
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
private _getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
const existingEmails = new Set<string>();
const distinctUsers = orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
@@ -394,7 +427,7 @@ export class RiskInsightsReportService {
return distinctUsers;
}
private getFlattenedCipherDetails(
private _getFlattenedCipherDetails(
detail: CipherHealthReportDetail,
uri: string,
): CipherHealthReportUriDetail {
@@ -409,7 +442,7 @@ export class RiskInsightsReportService {
};
}
private getMemberDetailsFlat(
private _getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
@@ -433,7 +466,7 @@ export class RiskInsightsReportService {
* @param cipher
* @returns distinct list of trimmed cipher uris
*/
private getTrimmedCipherUris(cipher: CipherView): string[] {
private _getTrimmedCipherUris(cipher: CipherView): string[] {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
@@ -445,7 +478,7 @@ export class RiskInsightsReportService {
return cipherUris;
}
private isUserNameNotEmpty(c: CipherView): boolean {
private _isUserNameNotEmpty(c: CipherView): boolean {
return !Utils.isNullOrWhitespace(c.login.username);
}
@@ -454,7 +487,7 @@ export class RiskInsightsReportService {
* is not deleted, and the user can view the password
* @param c the input cipher
*/
private validateCipher(c: CipherView): boolean {
private _validateCipher(c: CipherView): boolean {
const { type, login, isDeleted, viewPassword } = c;
if (
type !== CipherType.Login ||

View File

@@ -0,0 +1,91 @@
<!-- Loading -->
<div *ngIf="store.loading()">
<tools-risk-insights-loading></tools-risk-insights-loading>
</div>
<!-- Header -->
<div class="tw-mt-4" *ngIf="!store.loading() && !store.applicationsWithCriticalFlag().length">
<bit-no-items [icon]="noItemsIcon" class="tw-text-main">
<!-- Title -->
<ng-container slot="title">
<h2 class="tw-font-semibold tw-mt-4">
{{ "noAppsInOrgTitle" | i18n: store.currentOrganization()?.name }}
</h2>
</ng-container>
<!-- Description -->
<ng-container slot="description">
<div class="tw-flex tw-flex-col tw-mb-2">
<span class="tw-text-muted">
{{ "noAppsInOrgDescription" | i18n }}
</span>
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
</div>
</ng-container>
</bit-no-items>
</div>
<!-- Cards for stats -->
<div
class="tw-mt-4 tw-flex tw-flex-col"
*ngIf="!store.loading() && store.applicationsWithCriticalFlag().length"
>
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-gap-6">
<dirt-card
#allAppsOrgAtRiskMembers
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{ 'tw-bg-primary-100': store.drawerInvokerId() === 'allAppsOrgAtRiskMembers' }"
[title]="'atRiskMembers' | i18n"
[value]="store.summary().totalAtRiskMemberCount"
[maxValue]="store.summary().totalMemberCount"
(click)="store.showAllAtRiskMembers()"
>
</dirt-card>
<dirt-card
#allAppsOrgAtRiskApplications
class="tw-flex-1 tw-cursor-pointer"
[ngClass]="{
'tw-bg-primary-100': store.drawerInvokerId() === 'allAppsOrgAtRiskApplications',
}"
[title]="'atRiskApplications' | i18n"
[value]="store.summary().totalAtRiskApplicationCount"
[maxValue]="store.summary().totalApplicationCount"
(click)="store.showAtRiskApplications()"
>
</dirt-card>
</div>
<!-- Search -->
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4">
<bit-search
[placeholder]="'searchApps' | i18n"
class="tw-grow"
[formControl]="searchControl"
></bit-search>
<button
type="button"
[buttonType]="'primary'"
bitButton
[disabled]="!store.selectedEntityIds().size"
[loading]="store.isMarkingAppsAsCritical()"
(click)="store.markAppsAsCritical()"
>
<i
class="bwi tw-mr-2"
[ngClass]="store.selectedEntityIds().size ? 'bwi-star-f' : 'bwi-star'"
></i>
{{ "markAppAsCritical" | i18n }}
</button>
</div>
<!-- Table with all the application data -->
<app-table-row-scrollable
[dataSource]="dataSource"
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="store.selectedApplicationsIds()"
[selectedApplication]="store.drawerInvokerId()"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="store.showAtRiskApplicationMembers"
></app-table-row-scrollable>
</div>

View File

@@ -0,0 +1,70 @@
import { Component, DestroyRef, effect, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { debounceTime } from "rxjs";
import { ApplicationHealthReportDetailWithCriticalFlag } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import {
IconButtonModule,
Icons,
NoItemsModule,
SearchModule,
TableDataSource,
} from "@bitwarden/components";
import { CardComponent } from "@bitwarden/dirt-card";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { AppTableRowScrollableComponent } from "../app-table-row-scrollable/app-table-row-scrollable.component";
import { ApplicationsLoadingComponent } from "../risk-insights-loading/risk-insights-loading.component";
import { RiskInsightsStore } from "../risk-insights.store";
@Component({
standalone: true,
selector: "tools-all-applications-with-signals",
templateUrl: "./all-applications.component.html",
imports: [
ApplicationsLoadingComponent,
HeaderModule,
SearchModule,
PipesModule,
NoItemsModule,
SharedModule,
CardComponent,
AppTableRowScrollableComponent,
IconButtonModule,
],
// provide the store at the component level (can be done at the root level as well)
})
export class AllApplicationsComponentWithSignals {
readonly store = inject(RiskInsightsStore);
protected dataSource = new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
protected searchControl = new FormControl("", { nonNullable: true });
noItemsIcon = Icons.Security;
destroyRef = inject(DestroyRef);
constructor() {
const applicationsWithCriticalFlag = this.store.applicationsWithCriticalFlag;
effect(() => {
// Update table data when data changes
this.dataSource.data = applicationsWithCriticalFlag();
});
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
onCheckboxChange = (applicationName: string, event: Event) => {
const isChecked = (event.target as HTMLInputElement).checked;
if (isChecked) {
this.store.selectApplication(applicationName);
} else {
this.store.deselectApplication(applicationName);
}
};
}

View File

@@ -0,0 +1,197 @@
import { computed, effect, inject } from "@angular/core";
import {
patchState,
signalStore,
withComputed,
withFeature,
withHooks,
withMethods,
withProps,
withState,
} from "@ngrx/signals";
import { EntityId } from "@ngrx/signals/entities";
import {
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
AppAtRiskMembersDialogParams,
ApplicationHealthReportSummary,
AtRiskApplicationDetail,
AtRiskMemberDetail,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { withActivatedRouteFeature } from "../../signal-store-features/activated-route.feature";
import { withActiveAccountFeature } from "../../signal-store-features/active-account.feature";
import {
setMarkingCriticalApps,
withCriticalApplicationsFeature,
} from "../../signal-store-features/critical-applications.feature";
import {
closeDrawer,
openDrawerForApplicationMembers,
openDrawerForApplications,
openDrawerForOrganizationMembers,
withDrawerFeature,
} from "../../signal-store-features/drawer.feature";
import {
setCurrentOrganizationId,
withOrganizationFeature,
} from "../../signal-store-features/organizations.feature";
import { withRiskInsightsReportsFeature } from "../../signal-store-features/risk-insights-reports.feature";
import {
addSelectedEntityId,
clearSelectedEntityIds,
removeSelectedEntityId,
} from "../../signal-store-features/selected-entities.feature";
// Setup the initial state for the store
type ApplicationStoreState = {
applicationSummary: ApplicationHealthReportSummary;
initialized: boolean;
isLoading: boolean;
loadingCriticalApps: boolean;
organization: any;
atRiskAllMembers: AtRiskMemberDetail[];
atRiskApplications: AtRiskApplicationDetail[];
atRiskApplicationMembers: AppAtRiskMembersDialogParams | null;
};
const initialState: ApplicationStoreState = {
applicationSummary: {
totalMemberCount: 0,
totalAtRiskMemberCount: 0,
totalApplicationCount: 0,
totalAtRiskApplicationCount: 0,
},
initialized: false,
isLoading: false,
loadingCriticalApps: false,
organization: undefined,
atRiskAllMembers: [],
atRiskApplications: [],
atRiskApplicationMembers: null,
};
export const ApplicationStore = signalStore(
withState(initialState),
withProps(() => ({
toastService: inject(ToastService),
dataService: inject(RiskInsightsDataService),
reportService: inject(RiskInsightsReportService),
i18nService: inject(I18nService),
})),
withDrawerFeature(), // Adds drawer functionality
withActivatedRouteFeature(),
withActiveAccountFeature(),
withFeature(({ activeAccountUserId }) => withOrganizationFeature(activeAccountUserId)),
withFeature(({ currentOrganizationId }) =>
withCriticalApplicationsFeature(currentOrganizationId),
),
withRiskInsightsReportsFeature(),
// withFeature(({ currentOrganizationId }) => withRiskInsightsReportsFeature(currentOrganizationId)),
withComputed(({ entities, criticalApps, selectedEntityIds, reportService }) => {
return {
// table data
// Expose drawer invoker ID for the table to use
// tableDataSource: computed(() => {
// const tableDataSource =
// new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
// tableDataSource.data = entities();
// return tableDataSource;
// }),
selectedApplicationsIds: computed(() => {
const stringIds = new Set<string>();
selectedEntityIds().forEach((id: EntityId) => {
stringIds.add(id as string);
});
return stringIds;
}),
applicationsCount: computed(() => entities().length),
applicationsWithCriticalFlag: computed(() => {
const apps = entities();
return apps.map((app) => ({
...app,
isMarkedAsCritical: criticalApps()
.map((ca) => ca.uri)
.includes(app.applicationName),
}));
}),
summary: computed(() => reportService.generateApplicationsSummary(entities())),
};
}),
withMethods(({ dataService, reportService, i18nService, toastService, ...store }) => ({
selectApplication(id: EntityId): void {
patchState(store, addSelectedEntityId(store.selectedEntityIds(), id));
},
deselectApplication(id: EntityId): void {
patchState(store, removeSelectedEntityId(store.selectedEntityIds(), id));
},
isDrawerOpenForTableRow(applicationName: string): boolean {
return store.drawerInvokerId() === applicationName;
},
markAppsAsCritical: async () => {
patchState(store, setMarkingCriticalApps(true));
try {
await store.saveCriticalApps(
// store.currentOrganizationId(),
Array.from(store.selectedApplicationsIds()),
);
// Use the toast feature from the store
toastService.showToast({
variant: "success",
title: "",
message: i18nService.t("applicationsMarkedAsCriticalSuccess"),
});
patchState(store, clearSelectedEntityIds());
} finally {
patchState(store, setMarkingCriticalApps(false));
}
},
closeDrawer: () => {
patchState(store, closeDrawer());
},
showAllAtRiskMembers: () => {
const atRiskAllMembers = reportService.generateAtRiskMemberList(store.entities());
patchState(store, { atRiskAllMembers }, openDrawerForOrganizationMembers());
},
showAtRiskApplications: () => {
// TODO: This should be moved to the report service
const atRiskApplications = reportService.generateAtRiskApplicationList(store.entities());
patchState(store, { atRiskApplications }, openDrawerForApplications());
},
showAtRiskApplicationMembers: (applicationName: string) => {
const atRiskApplicationMembers = {
members:
store.entities().find((app: any) => app.applicationName === applicationName)
?.atRiskMemberDetails ?? [],
applicationName,
};
patchState(
store,
{
atRiskApplicationMembers,
},
openDrawerForApplicationMembers(applicationName),
);
},
})),
withHooks({
onInit(store) {
// Watch for changes in the route params for organizationId
effect(() => {
const orgIdParam = store.activatedRouteParams()?.get("organizationId");
if (orgIdParam) {
patchState(store, setCurrentOrganizationId(orgIdParam));
}
});
},
}),
);

View File

@@ -16,11 +16,11 @@
<a class="tw-text-primary-600" routerLink="/login">{{ "learnMore" | i18n }}</a>
</div>
</ng-container>
<ng-container slot="button">
<!-- <ng-container slot="button">
<button (click)="goToCreateNewLoginItem()" bitButton buttonType="primary" type="button">
{{ "createNewLoginItem" | i18n }}
</button>
</ng-container>
</ng-container> -->
</bit-no-items>
</div>
<div class="tw-mt-4 tw-flex tw-flex-col" *ngIf="!(isLoading$ | async) && dataSource.data.length">
@@ -73,7 +73,7 @@
[showRowCheckBox]="true"
[showRowMenuForCriticalApps]="false"
[selectedUrls]="selectedUrls"
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
[isRowSelected]="isDrawerOpenForTableRow"
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable>

View File

@@ -10,7 +10,7 @@ import {
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
ApplicationHealthReportDetail,
// ApplicationHealthReportDetail,
ApplicationHealthReportDetailWithCriticalFlag,
ApplicationHealthReportSummary,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
@@ -85,7 +85,7 @@ export class AllApplicationsComponent implements OnInit {
combineLatest([
this.dataService.applications$,
this.criticalAppsService.getAppsListForOrg(organizationId),
this.criticalAppsService.generateAppsListForOrg$(organizationId),
organization$,
])
.pipe(
@@ -131,18 +131,18 @@ export class AllApplicationsComponent implements OnInit {
.subscribe((v) => (this.dataSource.filter = v));
}
goToCreateNewLoginItem = async () => {
// TODO: implement
this.toastService.showToast({
variant: "warning",
title: "",
message: "Not yet implemented",
});
};
// goToCreateNewLoginItem = async () => {
// // TODO: implement
// this.toastService.showToast({
// variant: "warning",
// title: "",
// message: "Not yet implemented",
// });
// };
isMarkedAsCriticalItem(applicationName: string) {
return this.selectedUrls.has(applicationName);
}
// isMarkedAsCriticalItem(applicationName: string) {
// return this.selectedUrls.has(applicationName);
// }
markAppsAsCritical = async () => {
this.markingAsCritical = true;
@@ -164,9 +164,9 @@ export class AllApplicationsComponent implements OnInit {
}
};
trackByFunction(_: number, item: ApplicationHealthReportDetail) {
return item.applicationName;
}
// trackByFunction(_: number, item: ApplicationHealthReportDetail) {
// return item.applicationName;
// }
showAppAtRiskMembers = async (applicationName: string) => {
const info = {

View File

@@ -13,7 +13,7 @@
<td
bitCell
*ngIf="showRowCheckBox"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
>
<input
bitCheckbox
@@ -27,7 +27,7 @@
<td
bitCell
*ngIf="!showRowCheckBox"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
>
<i class="bwi bwi-star-f" *ngIf="row.isMarkedAsCritical"></i>
</td>
@@ -36,33 +36,24 @@
</td>
<td
class="tw-cursor-pointer"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
(click)="showAppAtRiskMembers(row.applicationName)"
(keypress)="showAppAtRiskMembers(row.applicationName)"
bitCell
>
<span>{{ row.applicationName }}</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
<span>
{{ row.atRiskPasswordCount }}
</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
<span>
{{ row.passwordCount }}
</span>
</td>
<td
bitCell
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
>
<td bitCell [ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }">
<span>
{{ row.atRiskMemberCount }}
</span>
@@ -70,14 +61,14 @@
<td
bitCell
data-testid="total-membership"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
>
{{ row.memberCount }}
</td>
<td
bitCell
*ngIf="showRowMenuForCriticalApps"
[ngClass]="{ 'tw-bg-primary-100': isDrawerIsOpenForThisRecord(row.applicationName) }"
[ngClass]="{ 'tw-bg-primary-100': isRowSelected(row.applicationName) }"
>
<button
[bitMenuTriggerFor]="rowMenu"

View File

@@ -18,8 +18,12 @@ export class AppTableRowScrollableComponent {
@Input() showRowMenuForCriticalApps: boolean = false;
@Input() showRowCheckBox: boolean = false;
@Input() selectedUrls: Set<string> = new Set<string>();
@Input() isDrawerIsOpenForThisRecord!: (applicationName: string) => boolean;
@Input() selectedApplication: string;
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
@Input() unmarkAsCriticalApp!: (applicationName: string) => void;
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;
isRowSelected(applicationName: string): boolean {
return this.selectedApplication === applicationName;
}
}

View File

@@ -82,7 +82,7 @@
[dataSource]="dataSource"
[showRowCheckBox]="false"
[showRowMenuForCriticalApps]="true"
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
[selectedApplication]="dataService.drawerInvokerId"
[showAppAtRiskMembers]="showAppAtRiskMembers"
[unmarkAsCriticalApp]="unmarkAsCriticalApp"
></app-table-row-scrollable>

View File

@@ -36,6 +36,7 @@ import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-t
import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service";
import { AppTableRowScrollableComponent } from "../app-table-row-scrollable/app-table-row-scrollable.component";
import { RiskInsightsTabType } from "../risk-insights.component";
import { RiskInsightsStore } from "../risk-insights.store";
@Component({
standalone: true,
@@ -64,6 +65,8 @@ export class CriticalApplicationsComponent implements OnInit {
isNotificationsFeatureEnabled: boolean = false;
enableRequestPasswordChange = false;
readonly store = inject(RiskInsightsStore);
async ngOnInit() {
this.isNotificationsFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.EnableRiskInsightsNotifications,
@@ -71,7 +74,7 @@ export class CriticalApplicationsComponent implements OnInit {
this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? "";
combineLatest([
this.dataService.applications$,
this.criticalAppsService.getAppsListForOrg(this.organizationId),
this.criticalAppsService.generateAppsListForOrg$(this.organizationId),
])
.pipe(
takeUntilDestroyed(this.destroyRef),
@@ -87,7 +90,7 @@ export class CriticalApplicationsComponent implements OnInit {
.subscribe((applications) => {
if (applications) {
this.dataSource.data = applications;
this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
// this.applicationSummary = this.reportService.generateApplicationsSummary(applications);
this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0;
}
});
@@ -192,7 +195,4 @@ export class CriticalApplicationsComponent implements OnInit {
trackByFunction(_: number, item: ApplicationHealthReportDetailWithCriticalFlag) {
return item.applicationName;
}
isDrawerOpenForTableRow = (applicationName: string) => {
return this.dataService.drawerInvokerId === applicationName;
};
}

View File

@@ -1,51 +0,0 @@
<bit-container>
<p>{{ "passwordsReportDesc" | i18n }}</p>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="tw-mt-4" *ngIf="!loading">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell bitSortable="hostURI">{{ "application" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "totalMembers" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<ng-container>
<span>{{ row.hostURI }}</span>
</ng-container>
</td>
<td bitCell class="tw-text-right">
<span
bitBadge
*ngIf="passwordStrengthMap.has(row.id)"
[variant]="passwordStrengthMap.get(row.id)[1]"
>
{{ passwordStrengthMap.get(row.id)[0] | i18n }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge *ngIf="exposedPasswordMap.has(row.id)" variant="warning">
{{ "exposedXTimes" | i18n: exposedPasswordMap.get(row.id) }}
</span>
</td>
<td bitCell class="tw-text-right" data-testid="total-membership">
{{ totalMembersMap.get(row.id) || 0 }}
</td>
</ng-template>
</bit-table-scroll>
</div>
</bit-container>

View File

@@ -1,77 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import {
MemberCipherDetailsApiService,
PasswordHealthService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TableModule } from "@bitwarden/components";
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { PasswordHealthMembersURIComponent } from "./password-health-members-uri.component";
describe("PasswordHealthMembersUriComponent", () => {
let component: PasswordHealthMembersURIComponent;
let fixture: ComponentFixture<PasswordHealthMembersURIComponent>;
let cipherServiceMock: MockProxy<CipherService>;
const passwordHealthServiceMock = mock<PasswordHealthService>();
const userId = Utils.newGuid() as UserId;
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
beforeEach(async () => {
cipherServiceMock = mock<CipherService>();
await TestBed.configureTestingModule({
imports: [PasswordHealthMembersURIComponent, PipesModule, TableModule, LooseComponentsModule],
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: AuditService, useValue: mock<AuditService>() },
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: AccountService, useValue: mockAccountServiceWith(userId) },
{
provide: PasswordStrengthServiceAbstraction,
useValue: mock<PasswordStrengthServiceAbstraction>(),
},
{ provide: PasswordHealthService, useValue: passwordHealthServiceMock },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(activeRouteParams),
url: of([]),
},
},
{
provide: MemberCipherDetailsApiService,
useValue: mock<MemberCipherDetailsApiService>(),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PasswordHealthMembersURIComponent);
component = fixture.componentInstance;
});
it("should initialize component", () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,108 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
MemberCipherDetailsApiService,
PasswordHealthService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
BadgeVariant,
ContainerComponent,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
@Component({
standalone: true,
selector: "tools-password-health-members-uri",
templateUrl: "password-health-members-uri.component.html",
imports: [
BadgeModule,
CommonModule,
ContainerComponent,
PipesModule,
JslibModule,
HeaderModule,
TableModule,
],
providers: [PasswordHealthService, MemberCipherDetailsApiService],
})
export class PasswordHealthMembersURIComponent implements OnInit {
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
weakPasswordCiphers: CipherView[] = [];
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
totalMembersMap = new Map<string, number>();
dataSource = new TableDataSource<CipherView>();
reportCiphers: (CipherView & { hostURI: string })[] = [];
reportCipherURIs: string[] = [];
organization: Organization;
loading = true;
private destroyRef = inject(DestroyRef);
constructor(
protected cipherService: CipherService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected organizationService: OrganizationService,
protected auditService: AuditService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
protected memberCipherDetailsApiService: MemberCipherDetailsApiService,
) {}
ngOnInit() {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map(async (params) => {
const organizationId = params.get("organizationId");
await this.setCiphers(organizationId);
}),
)
.subscribe();
}
async setCiphers(organizationId: string) {
const passwordHealthService = new PasswordHealthService(
this.passwordStrengthService,
this.auditService,
this.cipherService,
this.memberCipherDetailsApiService,
organizationId,
);
await passwordHealthService.generateReport();
this.dataSource.data = passwordHealthService.groupCiphersByLoginUri();
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
this.passwordUseMap = passwordHealthService.passwordUseMap;
this.totalMembersMap = passwordHealthService.totalMembersMap;
this.loading = false;
}
}

View File

@@ -1,60 +0,0 @@
<p>{{ "passwordsReportDesc" | i18n }}</p>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="tw-flex tw-flex-col" *ngIf="!loading && dataSource.data.length">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="74">
<ng-container header>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "totalMembers" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<input
bitCheckbox
type="checkbox"
[checked]="selectedIds.has(row.id)"
(change)="onCheckboxChange(row.id, $event)"
/>
</td>
<td bitCell>
<ng-container>
<span>{{ row.name }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell class="tw-text-right">
<span
bitBadge
*ngIf="passwordStrengthMap.has(row.id)"
[variant]="passwordStrengthMap.get(row.id)[1]"
>
{{ passwordStrengthMap.get(row.id)[0] | i18n }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge *ngIf="exposedPasswordMap.has(row.id)" variant="warning">
{{ "exposedXTimes" | i18n: exposedPasswordMap.get(row.id) }}
</span>
</td>
<td bitCell class="tw-text-right" data-testid="total-membership">
{{ totalMembersMap.get(row.id) || 0 }}
</td>
</ng-template>
</bit-table-scroll>
</div>

View File

@@ -1,128 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormsModule } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { debounceTime, map } from "rxjs";
import {
MemberCipherDetailsApiService,
PasswordHealthService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeVariant,
SearchModule,
TableDataSource,
TableModule,
ToastService,
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
@Component({
standalone: true,
selector: "tools-password-health-members",
templateUrl: "password-health-members.component.html",
imports: [PipesModule, HeaderModule, SearchModule, FormsModule, SharedModule, TableModule],
providers: [PasswordHealthService, MemberCipherDetailsApiService],
})
export class PasswordHealthMembersComponent implements OnInit {
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
passwordUseMap = new Map<string, number>();
exposedPasswordMap = new Map<string, number>();
totalMembersMap = new Map<string, number>();
dataSource = new TableDataSource<CipherView>();
loading = true;
selectedIds: Set<number> = new Set<number>();
protected searchControl = new FormControl("", { nonNullable: true });
private destroyRef = inject(DestroyRef);
constructor(
protected cipherService: CipherService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected auditService: AuditService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
protected toastService: ToastService,
protected memberCipherDetailsApiService: MemberCipherDetailsApiService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
ngOnInit() {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map(async (params) => {
const organizationId = params.get("organizationId");
await this.setCiphers(organizationId);
}),
)
.subscribe();
}
async setCiphers(organizationId: string) {
const passwordHealthService = new PasswordHealthService(
this.passwordStrengthService,
this.auditService,
this.cipherService,
this.memberCipherDetailsApiService,
organizationId,
);
await passwordHealthService.generateReport();
this.dataSource.data = passwordHealthService.reportCiphers;
this.exposedPasswordMap = passwordHealthService.exposedPasswordMap;
this.passwordStrengthMap = passwordHealthService.passwordStrengthMap;
this.passwordUseMap = passwordHealthService.passwordUseMap;
this.totalMembersMap = passwordHealthService.totalMembersMap;
this.loading = false;
}
markAppsAsCritical = async () => {
// TODO: Send to API once implemented
return new Promise((resolve) => {
setTimeout(() => {
this.selectedIds.clear();
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("appsMarkedAsCritical"),
});
resolve(true);
}, 1000);
});
};
trackByFunction(_: number, item: CipherView) {
return item.id;
}
onCheckboxChange(id: number, event: Event) {
const isChecked = (event.target as HTMLInputElement).checked;
if (isChecked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
}
}

View File

@@ -1,53 +0,0 @@
<bit-container>
<p>{{ "passwordsReportDesc" | i18n }}</p>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="tw-mt-4" *ngIf="!loading && dataSource.data.length">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container>
<span>{{ row.name }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell class="tw-text-right">
<span
bitBadge
*ngIf="row.weakPasswordDetail"
[variant]="row.weakPasswordDetail?.detailValue.badgeVariant"
>
{{ row.weakPasswordDetail?.detailValue.label | i18n }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge *ngIf="passwordUseMap.has(row.login.password)" variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
</span>
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning" *ngIf="row.exposedPasswordDetail">
{{ "exposedXTimes" | i18n: row.exposedPasswordDetail?.exposedXTimes }}
</span>
</td>
</ng-template>
</bit-table-scroll>
</div>
</bit-container>

View File

@@ -1,49 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { TableModule } from "@bitwarden/components";
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
import { PasswordHealthComponent } from "./password-health.component";
describe("PasswordHealthComponent", () => {
let component: PasswordHealthComponent;
let fixture: ComponentFixture<PasswordHealthComponent>;
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
declarations: [],
providers: [
{ provide: RiskInsightsReportService, useValue: mock<RiskInsightsReportService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(activeRouteParams),
url: of([]),
},
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PasswordHealthComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should initialize component", () => {
expect(component).toBeTruthy();
});
it("should call generateReport on init", () => {});
});

View File

@@ -1,70 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { CipherHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
ContainerComponent,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { OrganizationBadgeModule } from "@bitwarden/web-vault/app/vault/individual-vault/organization-badge/organization-badge.module";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
@Component({
standalone: true,
selector: "tools-password-health",
templateUrl: "password-health.component.html",
imports: [
BadgeModule,
OrganizationBadgeModule,
CommonModule,
ContainerComponent,
PipesModule,
JslibModule,
HeaderModule,
TableModule,
],
})
export class PasswordHealthComponent implements OnInit {
passwordUseMap = new Map<string, number>();
dataSource = new TableDataSource<CipherHealthReportDetail>();
loading = true;
private destroyRef = inject(DestroyRef);
constructor(
protected riskInsightsReportService: RiskInsightsReportService,
protected i18nService: I18nService,
protected activatedRoute: ActivatedRoute,
) {}
ngOnInit() {
this.activatedRoute.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map(async (params) => {
const organizationId = params.get("organizationId");
await this.setCiphers(organizationId);
}),
)
.subscribe();
}
async setCiphers(organizationId: string) {
this.dataSource.data = await firstValueFrom(
this.riskInsightsReportService.generateRawDataReport$(organizationId),
);
this.loading = false;
}
}

View File

@@ -5,7 +5,7 @@
{{ "reviewAtRiskPasswords" | i18n }}
</div>
<div
*ngIf="dataLastUpdated$ | async"
*ngIf="store.lastUpdated()"
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
>
<i
@@ -13,9 +13,9 @@
aria-hidden="true"
></i>
<span class="tw-mx-4">{{
"dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a")
"dataLastUpdated" | i18n: (store.lastUpdated() | date: "MMMM d, y 'at' h:mm a")
}}</span>
<span class="tw-flex tw-justify-center tw-w-16">
<!-- <span class="tw-flex tw-justify-center tw-w-16">
<a
*ngIf="!(isRefreshing$ | async)"
bitButton
@@ -31,56 +31,43 @@
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true"
></i>
</span>
</span>
</span>
</span> -->
</div>
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
<tools-all-applications></tools-all-applications>
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: store.applicationsCount() }}">
<tools-all-applications-with-signals></tools-all-applications-with-signals>
</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }}
{{ "criticalApplicationsWithCount" | i18n: store.criticalApps.length ?? 0 }}
</ng-template>
<tools-critical-applications></tools-critical-applications>
</bit-tab>
<bit-tab *ngIf="showDebugTabs" label="Raw Data">
<tools-password-health></tools-password-health>
</bit-tab>
<bit-tab *ngIf="showDebugTabs" label="Raw Data + members">
<tools-password-health-members></tools-password-health-members>
</bit-tab>
<bit-tab *ngIf="showDebugTabs" label="Raw Data + uri">
<tools-password-health-members-uri></tools-password-health-members-uri>
</bit-tab>
</bit-tab-group>
<bit-drawer
style="width: 30%"
[(open)]="dataService.openDrawer"
(openChange)="dataService.closeDrawer()"
>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskMembers)">
<bit-drawer style="width: 30%" [open]="store.drawerOpen()" (openChange)="store.closeDrawer()">
<ng-container *ngIf="store.isOrganizationMembersDrawerActive()">
<bit-drawer-header
title="{{ 'atRiskMembersWithCount' | i18n: dataService.atRiskMemberDetails.length }}"
title="{{ 'atRiskMembersWithCount' | i18n: store.atRiskAllMembers().length }}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
(dataService.atRiskMemberDetails.length > 0
(store.atRiskAllMembers().length > 0
? "atRiskMembersDescription"
: "atRiskMembersDescriptionNone"
) | i18n
}}</span>
<ng-container *ngIf="dataService.atRiskMemberDetails.length > 0">
<ng-container *ngIf="store.atRiskAllMembers().length > 0">
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-text-sm tw-font-bold">{{ "email" | i18n }}</div>
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "atRiskPasswords" | i18n }}
</div>
</div>
<ng-container *ngFor="let member of dataService.atRiskMemberDetails">
<ng-container *ngFor="let member of store.atRiskAllMembers()">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ member.email }}</div>
<div>{{ member.atRiskPasswordCount }}</div>
@@ -90,43 +77,43 @@
</bit-drawer-body>
</ng-container>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)">
<bit-drawer-header title="{{ dataService.appAtRiskMembers.applicationName }}">
<ng-container *ngIf="store.isActiveDrawerType('ApplicationMembers')">
<bit-drawer-header title="{{ store.atRiskApplicationMembers().applicationName }}">
</bit-drawer-header>
<bit-drawer-body>
<div bitTypography="body1" class="tw-mb-2">
{{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }}
{{ "atRiskMembersWithCount" | i18n: store.atRiskApplicationMembers().members.length }}
</div>
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
{{
(dataService.appAtRiskMembers.members.length > 0
(store.atRiskApplicationMembers().members.length > 0
? "atRiskMembersDescriptionWithApp"
: "atRiskMembersDescriptionWithAppNone"
) | i18n: dataService.appAtRiskMembers.applicationName
) | i18n: store.atRiskApplicationMembers().applicationName
}}
</div>
<div class="tw-mt-1">
<ng-container *ngFor="let member of dataService.appAtRiskMembers.members">
<ng-container *ngFor="let member of store.atRiskApplicationMembers().members">
<div>{{ member.email }}</div>
</ng-container>
</div>
</bit-drawer-body>
</ng-container>
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)">
<ng-container *ngIf="store.isActiveDrawerType('Applications')">
<bit-drawer-header
title="{{ 'atRiskApplicationsWithCount' | i18n: dataService.atRiskAppDetails.length }}"
title="{{ 'atRiskApplicationsWithCount' | i18n: store.atRiskApplications().length }}"
>
</bit-drawer-header>
<bit-drawer-body>
<span bitTypography="body2" class="tw-text-muted tw-text-sm">{{
(dataService.atRiskAppDetails.length > 0
(store.atRiskApplications().length > 0
? "atRiskApplicationsDescription"
: "atRiskApplicationsDescriptionNone"
) | i18n
}}</span>
<ng-container *ngIf="dataService.atRiskAppDetails.length > 0">
<ng-container *ngIf="store.atRiskApplications().length > 0">
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
{{ "application" | i18n }}
@@ -135,7 +122,7 @@
{{ "atRiskPasswords" | i18n }}
</div>
</div>
<ng-container *ngFor="let app of dataService.atRiskAppDetails">
<ng-container *ngFor="let app of store.atRiskApplications()">
<div class="tw-flex tw-justify-between tw-mt-2">
<div>{{ app.applicationName }}</div>
<div>{{ app.atRiskPasswordCount }}</div>

View File

@@ -1,23 +1,9 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { Component, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { EMPTY, Observable } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
CriticalAppsService,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
ApplicationHealthReportDetail,
DrawerType,
PasswordHealthReportApplicationsResponse,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -29,11 +15,9 @@ import {
} from "@bitwarden/components";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
import { AllApplicationsComponentWithSignals } from "./all-applications-v2/all-applications.component";
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
import { PasswordHealthComponent } from "./password-health/password-health.component";
import { PasswordHealthMembersComponent } from "./password-health-members/password-health-members.component";
import { PasswordHealthMembersURIComponent } from "./password-health-members-uri/password-health-members-uri.component";
import { RiskInsightsStore } from "./risk-insights.store";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
@@ -47,95 +31,49 @@ export enum RiskInsightsTabType {
standalone: true,
templateUrl: "./risk-insights.component.html",
imports: [
AllApplicationsComponent,
AllApplicationsComponentWithSignals,
AsyncActionsModule,
ButtonModule,
CommonModule,
CriticalApplicationsComponent,
JslibModule,
HeaderModule,
PasswordHealthComponent,
PasswordHealthMembersComponent,
PasswordHealthMembersURIComponent,
TabsModule,
DrawerComponent,
DrawerBodyComponent,
DrawerHeaderComponent,
LayoutComponent,
],
providers: [RiskInsightsStore],
})
export class RiskInsightsComponent implements OnInit {
export class RiskInsightsComponent {
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps;
dataLastUpdated: Date = new Date();
criticalApps$: Observable<PasswordHealthReportApplicationsResponse[]> = new Observable();
showDebugTabs: boolean = false;
appsCount: number = 0;
criticalAppsCount: number = 0;
notifiedMembersCount: number = 0;
private organizationId: string | null = null;
private destroyRef = inject(DestroyRef);
isLoading$: Observable<boolean> = new Observable<boolean>();
isRefreshing$: Observable<boolean> = new Observable<boolean>();
dataLastUpdated$: Observable<Date | null> = new Observable<Date | null>();
refetching: boolean = false;
readonly store = inject(RiskInsightsStore);
constructor(
private route: ActivatedRoute,
private router: Router,
private configService: ConfigService,
protected dataService: RiskInsightsDataService,
private criticalAppsService: CriticalAppsService,
) {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => {
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps;
});
const orgId = this.route.snapshot.paramMap.get("organizationId") ?? "";
this.criticalApps$ = this.criticalAppsService.getAppsListForOrg(orgId);
const currentOrganizationId = this.store.currentOrganizationId;
// Initialize the data source with the store's application reports
this.store.load(currentOrganizationId);
}
async ngOnInit() {
this.showDebugTabs = devFlagEnabled("showRiskInsightsDebug");
this.route.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map((params) => params.get("organizationId")),
switchMap((orgId: string | null) => {
if (orgId) {
this.organizationId = orgId;
this.dataService.fetchApplicationsReport(orgId);
this.isLoading$ = this.dataService.isLoading$;
this.isRefreshing$ = this.dataService.isRefreshing$;
this.dataLastUpdated$ = this.dataService.dataLastUpdated$;
return this.dataService.applications$;
} else {
return EMPTY;
}
}),
)
.subscribe({
next: (applications: ApplicationHealthReportDetail[] | null) => {
if (applications) {
this.appsCount = applications.length;
}
this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId);
},
});
}
/**
* Refreshes the data by re-fetching the applications report.
* This will automatically notify child components subscribed to the RiskInsightsDataService observables.
*/
refreshData(): void {
if (this.organizationId) {
this.dataService.refreshApplicationsReport(this.organizationId);
}
}
// /**
// * Refreshes the data by re-fetching the applications report.
// * This will automatically notify child components subscribed to the RiskInsightsDataService observables.
// */
// refreshData(): void {
// if (this.organizationId) {
// this.store.load(this.organizationId);
// }
// }
async onTabChange(newIndex: number): Promise<void> {
await this.router.navigate([], {
@@ -145,11 +83,6 @@ export class RiskInsightsComponent implements OnInit {
});
// close drawer when tabs are changed
this.dataService.closeDrawer();
}
// Get a list of drawer types
get drawerTypes(): typeof DrawerType {
return DrawerType;
this.store.closeDrawer();
}
}

View File

@@ -0,0 +1,191 @@
import { computed, effect, inject, Signal } from "@angular/core";
import {
patchState,
signalStore,
withComputed,
withFeature,
withHooks,
withMethods,
withProps,
withState,
} from "@ngrx/signals";
import { EntityId } from "@ngrx/signals/entities";
import {
RiskInsightsDataService,
RiskInsightsReportService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import {
AppAtRiskMembersDialogParams,
AtRiskApplicationDetail,
AtRiskMemberDetail,
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { withActivatedRouteFeature } from "../signal-store-features/activated-route.feature";
import { withActiveAccountFeature } from "../signal-store-features/active-account.feature";
import {
setMarkingCriticalApps,
withCriticalApplicationsFeature,
} from "../signal-store-features/critical-applications.feature";
import {
closeDrawer,
openDrawerForApplicationMembers,
openDrawerForApplications,
openDrawerForOrganizationMembers,
withDrawerFeature,
} from "../signal-store-features/drawer.feature";
import {
setCurrentOrganizationId,
withOrganizationFeature,
} from "../signal-store-features/organizations.feature";
import { withRiskInsightsReportsFeature } from "../signal-store-features/risk-insights-reports.feature";
import {
addSelectedEntityId,
clearSelectedEntityIds,
removeSelectedEntityId,
} from "../signal-store-features/selected-entities.feature";
// Setup the initial state for the store
type RiskInsightsStoreState = {
initialized: boolean;
organization: any;
atRiskAllMembers: AtRiskMemberDetail[];
atRiskApplications: AtRiskApplicationDetail[];
atRiskApplicationMembers: AppAtRiskMembersDialogParams | null;
};
const initialState: RiskInsightsStoreState = {
initialized: false,
organization: undefined,
atRiskAllMembers: [],
atRiskApplications: [],
atRiskApplicationMembers: null,
};
export const RiskInsightsStore = signalStore(
withState(initialState),
withProps(() => ({
toastService: inject(ToastService),
dataService: inject(RiskInsightsDataService),
reportService: inject(RiskInsightsReportService),
i18nService: inject(I18nService),
})),
withDrawerFeature(), // Adds drawer functionality
withActivatedRouteFeature(),
withActiveAccountFeature(),
withFeature(({ activeAccountUserId }) => withOrganizationFeature(activeAccountUserId)),
withFeature(({ currentOrganizationId }) =>
withCriticalApplicationsFeature(currentOrganizationId),
),
withRiskInsightsReportsFeature(),
withComputed(
({
entities,
criticalApps,
selectedEntityIds,
reportService,
loadingCriticalApps,
loadingApplicationReports,
}) => {
return {
selectedApplicationsIds: computed(() => {
const stringIds = new Set<string>();
selectedEntityIds().forEach((id: EntityId) => {
stringIds.add(id as string);
});
return stringIds;
}),
applicationsCount: computed(() => entities().length),
applicationsWithCriticalFlag: computed(() => {
const apps = entities();
return apps.map((app) => ({
...app,
isMarkedAsCritical: criticalApps()
.map((ca) => ca.uri)
.includes(app.applicationName),
}));
}),
applicationReports: computed(() => entities()),
summary: computed(() => reportService.generateApplicationsSummary(entities())),
loading: computed(() => loadingCriticalApps() || loadingApplicationReports()),
};
},
),
withMethods(({ dataService, reportService, i18nService, toastService, ...store }) => ({
selectApplication(id: EntityId): void {
patchState(store, addSelectedEntityId(store.selectedEntityIds(), id));
},
deselectApplication(id: EntityId): void {
patchState(store, removeSelectedEntityId(store.selectedEntityIds(), id));
},
isDrawerOpenForTableRow(applicationName: string): boolean {
return store.drawerInvokerId() === applicationName;
},
markAppsAsCritical: async () => {
patchState(store, setMarkingCriticalApps(true));
try {
await store.saveCriticalApps(
// store.currentOrganizationId(),
Array.from(store.selectedApplicationsIds()),
);
// Use the toast feature from the store
toastService.showToast({
variant: "success",
title: "",
message: i18nService.t("applicationsMarkedAsCriticalSuccess"),
});
patchState(store, clearSelectedEntityIds());
} finally {
patchState(store, setMarkingCriticalApps(false));
}
},
closeDrawer: () => {
patchState(store, closeDrawer());
},
showAllAtRiskMembers: () => {
const atRiskAllMembers = reportService.generateAtRiskMemberList(store.entities());
patchState(store, { atRiskAllMembers }, openDrawerForOrganizationMembers());
},
showAtRiskApplications: () => {
// TODO: This should be moved to the report service
const atRiskApplications = reportService.generateAtRiskApplicationList(store.entities());
patchState(store, { atRiskApplications }, openDrawerForApplications());
},
showAtRiskApplicationMembers: (applicationName: string) => {
const atRiskApplicationMembers = {
members:
store.entities().find((app: any) => app.applicationName === applicationName)
?.atRiskMemberDetails ?? [],
applicationName,
};
patchState(
store,
{
atRiskApplicationMembers,
},
openDrawerForApplicationMembers(applicationName),
);
},
load: (orgId: Signal<string>) => {
store.setOrganizationId(orgId);
store.loadApplicationReports(orgId);
store.loadCriticalApps(orgId);
},
})),
withHooks({
onInit(store) {
// Watch for changes in the route params for organizationId
effect(() => {
const orgIdParam = store.activatedRouteParams().get("organizationId");
if (orgIdParam) {
patchState(store, setCurrentOrganizationId(orgIdParam));
}
});
},
}),
);

View File

@@ -0,0 +1,21 @@
import { inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { signalStoreFeature, withComputed } from "@ngrx/signals";
/**
* Exposes values from the Angular ActivatedRoute
*
* @returns A feature that provides access to route parameters as a computed signal.
*/
export function withActivatedRouteFeature() {
return signalStoreFeature(
withComputed((_store) => {
const activatedRoute = inject(ActivatedRoute);
return {
activatedRouteParams: toSignal(activatedRoute.paramMap),
};
}),
);
}

View File

@@ -0,0 +1,24 @@
import { inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { signalStoreFeature, withComputed } from "@ngrx/signals";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
/**
* Exposes values from the `AccountService` as a feature for use in signal stores.
*
* @returns A feature that provides access to the active account and its user ID.
*/
export function withActiveAccountFeature() {
return signalStoreFeature(
withComputed((_store) => {
const accountService = inject(AccountService);
return {
activeAccount: toSignal(accountService.activeAccount$),
activeAccountUserId: toSignal(getUserId(accountService.activeAccount$)),
};
}),
);
}

View File

@@ -0,0 +1,117 @@
import { inject, Signal } from "@angular/core";
import { tapResponse } from "@ngrx/operators";
import {
PartialStateUpdater,
patchState,
signalStoreFeature,
withMethods,
withProps,
withState,
} from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { exhaustMap, filter, from, pipe, tap } from "rxjs";
import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { PasswordHealthReportApplicationsResponse } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { OrganizationId } from "@bitwarden/common/types/guid";
export type CriticalApplicationsFeatureState = {
isMarkingAppsAsCritical: boolean;
loadingCriticalApps: boolean;
criticalApps: PasswordHealthReportApplicationsResponse[];
};
export function setLoadingCriticalApps(
isLoading: boolean,
): PartialStateUpdater<CriticalApplicationsFeatureState> {
return (state) => ({
...state,
loadingCriticalApps: isLoading,
});
}
export function setCriticalApps(
applications: PasswordHealthReportApplicationsResponse[],
): PartialStateUpdater<CriticalApplicationsFeatureState> {
return (state) => ({
...state,
criticalApps: applications,
});
}
export function setMarkingCriticalApps(
isMarking: boolean,
): Partial<CriticalApplicationsFeatureState> {
return { isMarkingAppsAsCritical: isMarking };
}
/**
* Exposes values from the `CriticalApplicationsService` as a feature for use in signal stores.
*
* @returns A feature that provides access to the critical applications and.
*/
export function withCriticalApplicationsFeature(orgId: Signal<string>) {
return signalStoreFeature(
withState<CriticalApplicationsFeatureState>({
isMarkingAppsAsCritical: false,
loadingCriticalApps: false,
criticalApps: [],
}),
withProps(() => ({
criticalApplicationsService: inject(CriticalAppsService),
})),
withMethods((store) => ({
setOrganizationId: rxMethod<string>(
pipe(
filter((orgId) => !!orgId),
tap((orgId) => {
return store.criticalApplicationsService.setOrganizationId(orgId as OrganizationId);
}),
),
),
loadCriticalApps: rxMethod<string>(
exhaustMap((orgId) => {
const org = orgId;
// No organization ID provided, return empty state
if (!org) {
patchState(store, setCriticalApps([]));
return from([]); // Return an empty observable
}
patchState(store, setLoadingCriticalApps(true));
return store.criticalApplicationsService.generateAppsListForOrg$(org).pipe(
tapResponse({
next: (applications) => {
patchState(store, setCriticalApps(applications), setLoadingCriticalApps(false));
},
error: (err) => {
patchState(store, setCriticalApps([]), setLoadingCriticalApps(false));
// TODO: Handle error appropriately, e.g., show a toast notification
// console.error(err);
},
}),
);
}),
),
saveCriticalApps: rxMethod<string[]>(
exhaustMap((selectedUrls) => {
patchState(store, setMarkingCriticalApps(true));
return from(
store.criticalApplicationsService.setCriticalApps(orgId(), selectedUrls),
).pipe(
tapResponse({
next: (response) => {
patchState(store, setMarkingCriticalApps(false), setCriticalApps(response));
},
error: (err) => {
patchState(store, setMarkingCriticalApps(false));
// TODO: Handle error appropriately
// console.error(err);
},
}),
);
}),
),
})),
);
}

View File

@@ -0,0 +1,63 @@
import { computed } from "@angular/core";
import { signalStoreFeature, withComputed, withMethods, withState } from "@ngrx/signals";
export type DrawerContext = "None" | "OrganizationMembers" | "ApplicationMembers" | "Applications";
export interface DrawerState {
drawerOpen: boolean;
drawerInvokerId: string;
activeDrawerType: DrawerContext;
}
export function openDrawerForOrganizationMembers(): DrawerState {
return {
drawerOpen: true,
activeDrawerType: "OrganizationMembers",
drawerInvokerId: "",
};
}
export function openDrawerForApplicationMembers(drawerInvokerId: string = ""): DrawerState {
return {
drawerOpen: true,
activeDrawerType: "ApplicationMembers",
drawerInvokerId,
};
}
export function openDrawerForApplications(drawerInvokerId: string = ""): DrawerState {
return {
drawerOpen: true,
activeDrawerType: "Applications",
drawerInvokerId,
};
}
export function closeDrawer(): DrawerState {
return {
drawerOpen: false,
activeDrawerType: "None",
drawerInvokerId: "",
};
}
export function withDrawerFeature() {
return signalStoreFeature(
withState<DrawerState>({
drawerOpen: false,
drawerInvokerId: "",
activeDrawerType: "None",
}),
withComputed((store) => ({
isApplicationDrawerActive: computed(() => store.activeDrawerType() === "Applications"),
isApplicationMembersDrawerActive: computed(
() => store.activeDrawerType() === "ApplicationMembers",
),
isOrganizationMembersDrawerActive: computed(
() => store.activeDrawerType() === "OrganizationMembers",
),
})),
withMethods((store) => ({
isActiveDrawerType: (drawerType: DrawerContext): boolean => {
return store.activeDrawerType() === drawerType;
},
})),
);
}

View File

@@ -0,0 +1,47 @@
import { inject, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { signalStoreFeature, withComputed, withProps, withState } from "@ngrx/signals";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
export type OrganizationFeatureState = {
currentOrganizationId: string;
};
export function setCurrentOrganizationId(organizationId: string): OrganizationFeatureState {
return { currentOrganizationId: organizationId };
}
/**
* Exposes values from the `Organization` as a feature for use in signal stores.
*
* @returns A feature that provides access to the active account and its user ID.
*/
export function withOrganizationFeature(activeAccountUserId: Signal<UserId>) {
return signalStoreFeature(
withState<OrganizationFeatureState>({ currentOrganizationId: "" }),
withProps(() => ({
organizationService: inject(OrganizationService),
})),
withComputed(({ currentOrganizationId, organizationService }) => {
return {
organizations: toSignal(organizationService.organizations$(activeAccountUserId())),
currentOrganization: toSignal(
organizationService
.organizations$(activeAccountUserId())
.pipe(getOrganizationById(currentOrganizationId())),
),
};
}),
// withMethods(({ organizationService }) => {
// return {
// getOrganizationById: (id: string) =>
// organizationService.organizations$(activeAccountUserId()).pipe(getOrganizationById(id)),
// };
// }),
);
}

View File

@@ -0,0 +1,97 @@
import { inject } from "@angular/core";
import { tapResponse } from "@ngrx/operators";
import {
PartialStateUpdater,
patchState,
signalStoreFeature,
withMethods,
withProps,
withState,
} from "@ngrx/signals";
import { SelectEntityId, setAllEntities, withEntities } from "@ngrx/signals/entities";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { exhaustMap, from } from "rxjs";
import { RiskInsightsReportService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health";
import { withSelectedEntitiesFeature } from "./selected-entities.feature";
export type RiskInsightsReportsFeatureState = {
loadingApplicationReports: boolean;
lastUpdated: Date | null;
};
export function setLoadingApps(
isLoading: boolean,
): PartialStateUpdater<RiskInsightsReportsFeatureState> {
return (state) => ({
...state,
loadingApplicationReports: isLoading,
});
}
export function setLastUpdated(
lastUpdated: Date,
): PartialStateUpdater<RiskInsightsReportsFeatureState> {
return (state) => ({
...state,
lastUpdated,
});
}
export const applicationSelectId: SelectEntityId<ApplicationHealthReportDetail> = (app) =>
app.applicationName;
/**
* Exposes values from the `RiskInsightsReportsService` as a feature for use in signal stores.
*
* @returns A feature that provides access to the application reports.
*/
export function withRiskInsightsReportsFeature() {
return signalStoreFeature(
withState<RiskInsightsReportsFeatureState>({
loadingApplicationReports: false,
lastUpdated: null,
}),
withEntities<ApplicationHealthReportDetail>(),
withSelectedEntitiesFeature(),
withProps(() => ({
riskInsightsReportsService: inject(RiskInsightsReportService),
})),
withMethods((store) => ({
loadApplicationReports: rxMethod<string>(
exhaustMap((orgId) => {
// No organization ID provided, return empty state
if (!orgId) {
patchState(store, setAllEntities([], { selectId: applicationSelectId }));
}
patchState(store, setLoadingApps(true));
return store.riskInsightsReportsService.generateApplicationsReport$(orgId).pipe(
tapResponse({
next: (applications) => {
patchState(
store,
setAllEntities(applications, { selectId: applicationSelectId }),
setLastUpdated(new Date()),
setLoadingApps(false),
);
return from([]); // Return an empty observable
},
error: (err) => {
patchState(
store,
setAllEntities([], { selectId: applicationSelectId }),
setLoadingApps(false),
);
// TODO: Handle error appropriately, e.g., show a toast notification
// console.error(err);
},
}),
);
}),
),
})),
);
}

View File

@@ -0,0 +1,56 @@
import { computed } from "@angular/core";
import { signalStoreFeature, type, withComputed, withState } from "@ngrx/signals";
import { EntityId, EntityState } from "@ngrx/signals/entities";
type SelectedEntityIds = Set<EntityId>;
export type SelectedEntityState = { selectedEntityIds: SelectedEntityIds };
// Add an ID to the set (returns new state)
export function addSelectedEntityId(
currentSelectedEntityIds: SelectedEntityIds,
id: EntityId,
): SelectedEntityState {
return {
selectedEntityIds: new Set([...currentSelectedEntityIds, id]),
};
}
// Remove an ID from the set (returns new state)
export function removeSelectedEntityId(
currentSelectedEntityIds: SelectedEntityIds,
id: EntityId,
): SelectedEntityState {
const newSet = new Set(currentSelectedEntityIds);
newSet.delete(id);
return {
selectedEntityIds: newSet,
};
}
export function clearSelectedEntityIds(): SelectedEntityState {
return { selectedEntityIds: new Set<EntityId>() };
}
/**
* A feature that provides the ability to manage multiple selected entities
*
* @returns A feature that provides selected entity state management.
*/
export function withSelectedEntitiesFeature<Entity>() {
return signalStoreFeature(
{ state: type<EntityState<Entity>>() },
withState<SelectedEntityState>({ selectedEntityIds: new Set<EntityId>() }),
withComputed(({ entityMap, selectedEntityIds }) => ({
selectedEntities: computed(() => {
const entities: Array<Entity> = [];
selectedEntityIds().forEach((id) => {
const entity = entityMap()[id];
if (entity) {
entities.push(entity);
}
});
return entities;
}),
})),
);
}

View File

@@ -0,0 +1,27 @@
import { computed } from "@angular/core";
import { signalStoreFeature, type, withComputed, withState } from "@ngrx/signals";
import { EntityId, EntityState } from "@ngrx/signals/entities";
export type SelectedEntityState = { selectedEntityId: EntityId | null };
export function setSelectedEntityId(id: EntityId | null): SelectedEntityState {
return { selectedEntityId: id };
}
/**
* A feature that provides the ability to manage one selected entity
*
* @returns A feature that provides selected entity state management.
*/
export function withSelectedEntityFeature<Entity>() {
return signalStoreFeature(
{ state: type<EntityState<Entity>>() },
withState<SelectedEntityState>({ selectedEntityId: null }),
withComputed(({ entityMap, selectedEntityId }) => ({
selectedEntity: computed(() => {
const selectedId = selectedEntityId();
return selectedId ? entityMap()[selectedId] : null;
}),
})),
);
}

32
package-lock.json generated
View File

@@ -32,6 +32,8 @@
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
"@ng-select/ng-select": "14.9.0",
"@ngrx/operators": "19.2.1",
"@ngrx/signals": "19.2.1",
"argon2": "0.41.1",
"argon2-browser": "1.18.0",
"big-integer": "1.6.52",
@@ -7854,6 +7856,36 @@
"@angular/forms": "^19.0.0"
}
},
"node_modules/@ngrx/operators": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/@ngrx/operators/-/operators-19.2.1.tgz",
"integrity": "sha512-umjSny5nWe7+a3XPeyMfE8vjhXD4ec6nA/KSV7bQA43Yt3eW8cQQr5ng7UZOkC0rbqcBGpSsJPt5thTeXiMXQg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@ngrx/signals": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.2.1.tgz",
"integrity": "sha512-Tajd2TVjkxxyFMhnMSWLa5pAWfynjP0VM0B/BCMaLiBrwBBxybxRVENoUDU5tGyiKSax/2tBJC3+sOglmxm27A==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/core": "^19.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
},
"peerDependenciesMeta": {
"rxjs": {
"optional": true
}
}
},
"node_modules/@ngtools/webpack": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.14.tgz",

View File

@@ -168,6 +168,8 @@
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
"@ng-select/ng-select": "14.9.0",
"@ngrx/operators": "19.2.1",
"@ngrx/signals": "19.2.1",
"argon2": "0.41.1",
"argon2-browser": "1.18.0",
"big-integer": "1.6.52",