From b194cac82a51b89ca956761bf7ddfa54d1fee710 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:23:41 -0500 Subject: [PATCH] Update some components to use the new risk-insights or application signal stores --- .../services/critical-apps.service.ts | 18 +- .../services/risk-insights-report.service.ts | 181 +++++++++------- .../all-applications.component.html | 91 ++++++++ .../all-applications.component.ts | 70 +++++++ .../all-applications-v2/applications.store.ts | 197 ++++++++++++++++++ .../all-applications.component.html | 6 +- .../all-applications.component.ts | 32 +-- .../app-table-row-scrollable.component.html | 25 +-- .../app-table-row-scrollable.component.ts | 6 +- .../critical-applications.component.html | 2 +- .../critical-applications.component.ts | 10 +- ...password-health-members-uri.component.html | 51 ----- ...sword-health-members-uri.component.spec.ts | 77 ------- .../password-health-members-uri.component.ts | 108 ---------- .../password-health-members.component.html | 60 ------ .../password-health-members.component.ts | 128 ------------ .../password-health.component.html | 53 ----- .../password-health.component.spec.ts | 49 ----- .../password-health.component.ts | 70 ------- .../risk-insights.component.html | 63 +++--- .../risk-insights.component.ts | 111 ++-------- .../risk-insights.store.ts | 191 +++++++++++++++++ .../activated-route.feature.ts | 21 ++ .../active-account.feature.ts | 24 +++ .../critical-applications.feature.ts | 117 +++++++++++ .../signal-store-features/drawer.feature.ts | 63 ++++++ .../organizations.feature.ts | 47 +++++ .../risk-insights-reports.feature.ts | 97 +++++++++ .../selected-entities.feature.ts | 56 +++++ .../selected-entity.feature.ts | 27 +++ package-lock.json | 32 +++ package.json | 2 + 32 files changed, 1238 insertions(+), 847 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/applications.store.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health-members-uri/password-health-members-uri.component.html delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health-members-uri/password-health-members-uri.component.spec.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health-members-uri/password-health-members-uri.component.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health-members/password-health-members.component.html delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health-members/password-health-members.component.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health/password-health.component.html delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health/password-health.component.spec.ts delete mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/password-health/password-health.component.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.store.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/activated-route.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/active-account.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/critical-applications.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/drawer.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/organizations.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/risk-insights-reports.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entities.feature.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entity.feature.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts index b879ef94705..125283b9988 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts @@ -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 { + generateAppsListForOrg$(orgId: string): Observable { 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 { if (orgId === null) { @@ -149,7 +150,10 @@ export class CriticalAppsService { return result$ as Observable; } - private async filterNewEntries(orgId: OrganizationId, selectedUrls: string[]): Promise { + private async _filterNewEntries( + orgId: OrganizationId, + selectedUrls: string[], + ): Promise { 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[], diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index afd246e1836..d5cbbef17d7 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -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([]); + + /** + * 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 { - 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 { - 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 { const cipherHealthReports: CipherHealthReportDetail[] = []; const passwordUseMap = new Map(); - 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 { + private async _findExposedPasswords(ciphers: CipherView[]): Promise { const exposedDetails: ExposedPasswordDetail[] = []; const promises: Promise[] = []; 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(); 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 || diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.html new file mode 100644 index 00000000000..27d05ad3eff --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.html @@ -0,0 +1,91 @@ + +
+ +
+ + +
+ + + +

+ {{ "noAppsInOrgTitle" | i18n: store.currentOrganization()?.name }} +

+
+ + +
+ + {{ "noAppsInOrgDescription" | i18n }} + + {{ "learnMore" | i18n }} +
+
+
+
+ + +
+

{{ "allApplications" | i18n }}

+
+ + + + +
+ + +
+ + +
+ + + +
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.ts new file mode 100644 index 00000000000..3f30ade334d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/all-applications.component.ts @@ -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(); + 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); + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/applications.store.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/applications.store.ts new file mode 100644 index 00000000000..ec0c7643919 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications-v2/applications.store.ts @@ -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(); + + // tableDataSource.data = entities(); + // return tableDataSource; + // }), + selectedApplicationsIds: computed(() => { + const stringIds = new Set(); + 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)); + } + }); + }, + }), +); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html index d383d1153c7..146a4eafb29 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html @@ -16,11 +16,11 @@ {{ "learnMore" | i18n }} - +
@@ -73,7 +73,7 @@ [showRowCheckBox]="true" [showRowMenuForCriticalApps]="false" [selectedUrls]="selectedUrls" - [isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow" + [isRowSelected]="isDrawerOpenForTableRow" [checkboxChange]="onCheckboxChange" [showAppAtRiskMembers]="showAppAtRiskMembers" > diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index c1c9b672068..51981c922dc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -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 = { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable/app-table-row-scrollable.component.html index 383780b450c..b2b98b8eff0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable/app-table-row-scrollable.component.html @@ -13,7 +13,7 @@ @@ -36,33 +36,24 @@ {{ row.applicationName }} - + {{ row.atRiskPasswordCount }} - + {{ row.passwordCount }} - + {{ row.atRiskMemberCount }} @@ -70,14 +61,14 @@ {{ row.memberCount }}
{{ - "dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") + "dataLastUpdated" | i18n: (store.lastUpdated() | date: "MMMM d, y 'at' h:mm a") }} - +
- - + + - {{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} + {{ "criticalApplicationsWithCount" | i18n: store.criticalApps.length ?? 0 }} - - - - - - - - - - - + + {{ - (dataService.atRiskMemberDetails.length > 0 + (store.atRiskAllMembers().length > 0 ? "atRiskMembersDescription" : "atRiskMembersDescriptionNone" ) | i18n }} - +
{{ "email" | i18n }}
{{ "atRiskPasswords" | i18n }}
- +
{{ member.email }}
{{ member.atRiskPasswordCount }}
@@ -90,43 +77,43 @@ - - + +
- {{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }} + {{ "atRiskMembersWithCount" | i18n: store.atRiskApplicationMembers().members.length }}
{{ - (dataService.appAtRiskMembers.members.length > 0 + (store.atRiskApplicationMembers().members.length > 0 ? "atRiskMembersDescriptionWithApp" : "atRiskMembersDescriptionWithAppNone" - ) | i18n: dataService.appAtRiskMembers.applicationName + ) | i18n: store.atRiskApplicationMembers().applicationName }}
- +
{{ member.email }}
- + {{ - (dataService.atRiskAppDetails.length > 0 + (store.atRiskApplications().length > 0 ? "atRiskApplicationsDescription" : "atRiskApplicationsDescriptionNone" ) | i18n }} - +
{{ "application" | i18n }} @@ -135,7 +122,7 @@ {{ "atRiskPasswords" | i18n }}
- +
{{ app.applicationName }}
{{ app.atRiskPasswordCount }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 68694b4ea7e..0fa0b62e743 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -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 = 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 = new Observable(); - isRefreshing$: Observable = new Observable(); - dataLastUpdated$: Observable = new Observable(); - 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 { 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(); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.store.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.store.ts new file mode 100644 index 00000000000..0de35c396e3 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.store.ts @@ -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(); + 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) => { + 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)); + } + }); + }, + }), +); diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/activated-route.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/activated-route.feature.ts new file mode 100644 index 00000000000..ad6f8f8b2a4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/activated-route.feature.ts @@ -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), + }; + }), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/active-account.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/active-account.feature.ts new file mode 100644 index 00000000000..83f276c59c1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/active-account.feature.ts @@ -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$)), + }; + }), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/critical-applications.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/critical-applications.feature.ts new file mode 100644 index 00000000000..9fd0cb1acd8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/critical-applications.feature.ts @@ -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 { + return (state) => ({ + ...state, + loadingCriticalApps: isLoading, + }); +} + +export function setCriticalApps( + applications: PasswordHealthReportApplicationsResponse[], +): PartialStateUpdater { + return (state) => ({ + ...state, + criticalApps: applications, + }); +} + +export function setMarkingCriticalApps( + isMarking: boolean, +): Partial { + 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) { + return signalStoreFeature( + withState({ + isMarkingAppsAsCritical: false, + loadingCriticalApps: false, + criticalApps: [], + }), + withProps(() => ({ + criticalApplicationsService: inject(CriticalAppsService), + })), + withMethods((store) => ({ + setOrganizationId: rxMethod( + pipe( + filter((orgId) => !!orgId), + tap((orgId) => { + return store.criticalApplicationsService.setOrganizationId(orgId as OrganizationId); + }), + ), + ), + loadCriticalApps: rxMethod( + 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( + 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); + }, + }), + ); + }), + ), + })), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/drawer.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/drawer.feature.ts new file mode 100644 index 00000000000..549beb42451 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/drawer.feature.ts @@ -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({ + 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; + }, + })), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/organizations.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/organizations.feature.ts new file mode 100644 index 00000000000..dd117082738 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/organizations.feature.ts @@ -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) { + return signalStoreFeature( + withState({ 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)), + // }; + // }), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/risk-insights-reports.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/risk-insights-reports.feature.ts new file mode 100644 index 00000000000..a9398105b9d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/risk-insights-reports.feature.ts @@ -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 { + return (state) => ({ + ...state, + loadingApplicationReports: isLoading, + }); +} + +export function setLastUpdated( + lastUpdated: Date, +): PartialStateUpdater { + return (state) => ({ + ...state, + lastUpdated, + }); +} + +export const applicationSelectId: SelectEntityId = (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({ + loadingApplicationReports: false, + lastUpdated: null, + }), + withEntities(), + withSelectedEntitiesFeature(), + withProps(() => ({ + riskInsightsReportsService: inject(RiskInsightsReportService), + })), + withMethods((store) => ({ + loadApplicationReports: rxMethod( + 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); + }, + }), + ); + }), + ), + })), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entities.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entities.feature.ts new file mode 100644 index 00000000000..48192ea4a91 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entities.feature.ts @@ -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; +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() }; +} + +/** + * A feature that provides the ability to manage multiple selected entities + * + * @returns A feature that provides selected entity state management. + */ +export function withSelectedEntitiesFeature() { + return signalStoreFeature( + { state: type>() }, + withState({ selectedEntityIds: new Set() }), + withComputed(({ entityMap, selectedEntityIds }) => ({ + selectedEntities: computed(() => { + const entities: Array = []; + selectedEntityIds().forEach((id) => { + const entity = entityMap()[id]; + if (entity) { + entities.push(entity); + } + }); + return entities; + }), + })), + ); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entity.feature.ts b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entity.feature.ts new file mode 100644 index 00000000000..669ccd04424 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/signal-store-features/selected-entity.feature.ts @@ -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() { + return signalStoreFeature( + { state: type>() }, + withState({ selectedEntityId: null }), + withComputed(({ entityMap, selectedEntityId }) => ({ + selectedEntity: computed(() => { + const selectedId = selectedEntityId(); + return selectedId ? entityMap()[selectedId] : null; + }), + })), + ); +} diff --git a/package-lock.json b/package-lock.json index 01b58a11e2b..52bb8207e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e9d0ad6b03f..625a77240e7 100644 --- a/package.json +++ b/package.json @@ -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",