diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 70fc56cc2c..daa01edb3f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -176,6 +176,12 @@ "totalApplications": { "message": "Total applications" }, + "unmarkAsCriticalApp": { + "message": "Unmark as critical app" + }, + "criticalApplicationSuccessfullyUnmarked": { + "message": "Critical application successfully unmarked" + }, "whatTypeOfItem": { "message": "What type of item is this?" }, diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts index 947fc8a79d..7d5e5e255f 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts @@ -1,6 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Opaque } from "type-fest"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; @@ -113,3 +116,31 @@ export type AtRiskApplicationDetail = { applicationName: string; atRiskPasswordCount: number; }; + +/** + * Request to drop a password health report application + * Model is expected by the API endpoint + */ +export interface PasswordHealthReportApplicationDropRequest { + organizationId: OrganizationId; + passwordHealthReportApplicationIds: string[]; +} + +/** + * Response from the API after marking an app as critical + */ +export interface PasswordHealthReportApplicationsResponse { + id: PasswordHealthReportApplicationId; + organizationId: OrganizationId; + uri: string; +} +/* + * Request to save a password health report application + * Model is expected by the API endpoint + */ +export interface PasswordHealthReportApplicationsRequest { + organizationId: OrganizationId; + url: string; +} + +export type PasswordHealthReportApplicationId = Opaque; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts index 838dc2c824..5ed88c4cac 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.spec.ts @@ -3,12 +3,14 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { CriticalAppsApiService } from "./critical-apps-api.service"; import { + PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationId, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "./critical-apps.service"; +} from "../models/password-health"; + +import { CriticalAppsApiService } from "./critical-apps-api.service"; describe("CriticalAppsApiService", () => { let service: CriticalAppsApiService; @@ -76,4 +78,24 @@ describe("CriticalAppsApiService", () => { done(); }); }); + + it("should call apiService.send with correct parameters for DropCriticalApp", (done) => { + const request: PasswordHealthReportApplicationDropRequest = { + organizationId: "org1" as OrganizationId, + passwordHealthReportApplicationIds: ["123"], + }; + + apiService.send.mockReturnValue(Promise.resolve()); + + service.dropCriticalApp(request).subscribe(() => { + expect(apiService.send).toHaveBeenCalledWith( + "DELETE", + "/reports/password-health-report-application/", + request, + true, + true, + ); + done(); + }); + }); }); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts index edd2cf34b5..c02a3686df 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps-api.service.ts @@ -4,9 +4,10 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { + PasswordHealthReportApplicationDropRequest, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "./critical-apps.service"; +} from "../models/password-health"; export class CriticalAppsApiService { constructor(private apiService: ApiService) {} @@ -36,4 +37,16 @@ export class CriticalAppsApiService { return from(dbResponse as Promise); } + + dropCriticalApp(request: PasswordHealthReportApplicationDropRequest): Observable { + const dbResponse = this.apiService.send( + "DELETE", + "/reports/password-health-report-application/", + request, + true, + true, + ); + + return from(dbResponse as Promise); + } } diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts index c6c4562310..5b89a2abb1 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.spec.ts @@ -12,13 +12,14 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { CriticalAppsApiService } from "./critical-apps-api.service"; import { - CriticalAppsService, PasswordHealthReportApplicationId, PasswordHealthReportApplicationsRequest, PasswordHealthReportApplicationsResponse, -} from "./critical-apps.service"; +} from "../models/password-health"; + +import { CriticalAppsApiService } from "./critical-apps-api.service"; +import { CriticalAppsService } from "./critical-apps.service"; describe("CriticalAppsService", () => { let service: CriticalAppsService; @@ -139,4 +140,54 @@ describe("CriticalAppsService", () => { expect(res).toHaveLength(2); }); }); + + it("should drop a critical app", async () => { + // arrange + const orgId = "org1" as OrganizationId; + const selectedUrl = "https://example.com"; + + const initialList = [ + { id: "id1", organizationId: "org1", uri: "https://example.com" }, + { id: "id2", organizationId: "org1", uri: "https://example.org" }, + ] as PasswordHealthReportApplicationsResponse[]; + + service.setAppsInListForOrg(initialList); + + // act + await service.dropCriticalApp(orgId, selectedUrl); + + // expectations + expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({ + organizationId: orgId, + passwordHealthReportApplicationIds: ["id1"], + }); + expect(service.getAppsListForOrg(orgId)).toBeTruthy(); + service.getAppsListForOrg(orgId).subscribe((res) => { + expect(res).toHaveLength(1); + expect(res[0].uri).toBe("https://example.org"); + }); + }); + + it("should not drop a critical app if it does not exist", async () => { + // arrange + const orgId = "org1" as OrganizationId; + const selectedUrl = "https://nonexistent.com"; + + const initialList = [ + { id: "id1", organizationId: "org1", uri: "https://example.com" }, + { id: "id2", organizationId: "org1", uri: "https://example.org" }, + ] as PasswordHealthReportApplicationsResponse[]; + + service.setAppsInListForOrg(initialList); + + // act + await service.dropCriticalApp(orgId, selectedUrl); + + // expectations + expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled(); + expect(service.getAppsListForOrg(orgId)).toBeTruthy(); + service.getAppsListForOrg(orgId).subscribe((res) => { + expect(res).toHaveLength(2); + }); + }); }); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts index 10b7d3f1fb..00b4dc78da 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/critical-apps.service.ts @@ -12,7 +12,6 @@ import { takeUntil, zip, } from "rxjs"; -import { Opaque } from "type-fest"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; @@ -20,6 +19,11 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { + PasswordHealthReportApplicationsRequest, + PasswordHealthReportApplicationsResponse, +} from "../models/password-health"; + import { CriticalAppsApiService } from "./critical-apps-api.service"; /* Retrieves and decrypts critical apps for a given organization @@ -94,6 +98,25 @@ export class CriticalAppsService { this.orgId.next(orgId); } + // Drop a critical app for a given organization + // Only one app may be dropped at a time + async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) { + const app = this.criticalAppsList.value.find( + (f) => f.organizationId === orgId && f.uri === selectedUrl, + ); + + if (!app) { + return; + } + + await this.criticalAppsApiService.dropCriticalApp({ + organizationId: app.organizationId, + passwordHealthReportApplicationIds: [app.id], + }); + + this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl)); + } + private retrieveCriticalApps( orgId: OrganizationId | null, ): Observable { @@ -144,16 +167,3 @@ export class CriticalAppsService { return await Promise.all(criticalAppsPromises); } } - -export interface PasswordHealthReportApplicationsRequest { - organizationId: OrganizationId; - url: string; -} - -export interface PasswordHealthReportApplicationsResponse { - id: PasswordHealthReportApplicationId; - organizationId: OrganizationId; - uri: string; -} - -export type PasswordHealthReportApplicationId = Opaque; diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.html index 87b21c7c75..41d256c073 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.html @@ -95,6 +95,21 @@ {{ r.memberCount }} + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.ts index 450f0d5d66..84a32ffcd6 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/critical-applications.component.ts @@ -15,12 +15,15 @@ import { ApplicationHealthReportDetailWithCriticalFlag, ApplicationHealthReportSummary, } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService, Icons, NoItemsModule, SearchModule, TableDataSource, + ToastService, } from "@bitwarden/components"; import { CardComponent } from "@bitwarden/tools-card"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; @@ -37,6 +40,7 @@ import { RiskInsightsTabType } from "./risk-insights.component"; selector: "tools-critical-applications", templateUrl: "./critical-applications.component.html", imports: [CardComponent, HeaderModule, SearchModule, NoItemsModule, PipesModule, SharedModule], + providers: [], }) export class CriticalApplicationsComponent implements OnInit { protected dataSource = new TableDataSource(); @@ -80,13 +84,38 @@ export class CriticalApplicationsComponent implements OnInit { }); }; + unmarkAsCriticalApp = async (hostname: string) => { + try { + await this.criticalAppsService.dropCriticalApp( + this.organizationId as OrganizationId, + hostname, + ); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + return; + } + + this.toastService.showToast({ + message: this.i18nService.t("criticalApplicationSuccessfullyUnmarked"), + variant: "success", + title: this.i18nService.t("success"), + }); + this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname); + }; + constructor( protected activatedRoute: ActivatedRoute, protected router: Router, + protected toastService: ToastService, protected dataService: RiskInsightsDataService, protected criticalAppsService: CriticalAppsService, protected reportService: RiskInsightsReportService, protected dialogService: DialogService, + protected i18nService: I18nService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) diff --git a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts index 5adb0d3294..6d39a710e2 100644 --- a/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/tools/access-intelligence/risk-insights.component.ts @@ -2,16 +2,18 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { Observable, EMPTY } from "rxjs"; +import { EMPTY, Observable } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { - RiskInsightsDataService, CriticalAppsService, - PasswordHealthReportApplicationsResponse, + RiskInsightsDataService, } from "@bitwarden/bit-common/tools/reports/risk-insights"; -import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; +import { + ApplicationHealthReportDetail, + PasswordHealthReportApplicationsResponse, +} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health"; // eslint-disable-next-line no-restricted-imports -- used for dependency injection import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";