diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 11d2d8e3dd8c..7409a8f96935 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9752,6 +9752,9 @@ "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrgOwnerToPerformAction": { + "message": "You must be the organization owner to perform this action." + }, "failedToDeleteIntegration": { "message": "Failed to delete integration. Please try again later." }, diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts index ad3d67647131..6c6a086e0f5f 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationIntegrationId, @@ -20,6 +21,11 @@ import { OrganizationIntegrationType } from "../models/organization-integration- import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; +export type HecModificationFailureReason = { + mustBeOwner: boolean; + success: boolean; +}; + export class HecOrganizationIntegrationService { private organizationId$ = new BehaviorSubject(null); private _integrations$ = new BehaviorSubject([]); @@ -34,7 +40,7 @@ export class HecOrganizationIntegrationService { const data$ = await this.setIntegrations(orgId); return await firstValueFrom(data$); } else { - return this._integrations$.getValue(); + return [] as OrganizationIntegration[]; } }), takeUntil(this.destroy$), @@ -56,6 +62,10 @@ export class HecOrganizationIntegrationService { * @param orgId */ setOrganizationIntegrations(orgId: OrganizationId) { + if (orgId == this.organizationId$.getValue()) { + return; + } + this._integrations$.next([]); this.organizationId$.next(orgId); } @@ -73,31 +83,39 @@ export class HecOrganizationIntegrationService { url: string, bearerToken: string, index: string, - ) { + ): Promise { if (organizationId != this.organizationId$.getValue()) { throw new Error("Organization ID mismatch"); } - const hecConfig = new HecConfiguration(url, bearerToken, service); - const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( - organizationId, - new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), - ); - - const newTemplate = new HecTemplate(index, service); - const newIntegrationConfigResponse = - await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( + try { + const hecConfig = new HecConfiguration(url, bearerToken, service); + const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( organizationId, - newIntegrationResponse.id, - new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()), + new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), ); - const newIntegration = this.mapResponsesToOrganizationIntegration( - newIntegrationResponse, - newIntegrationConfigResponse, - ); - if (newIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), newIntegration]); + const newTemplate = new HecTemplate(index, service); + const newIntegrationConfigResponse = + await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( + organizationId, + newIntegrationResponse.id, + new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()), + ); + + const newIntegration = this.mapResponsesToOrganizationIntegration( + newIntegrationResponse, + newIntegrationConfigResponse, + ); + if (newIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), newIntegration]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; } } @@ -119,40 +137,48 @@ export class HecOrganizationIntegrationService { url: string, bearerToken: string, index: string, - ) { + ): Promise { if (organizationId != this.organizationId$.getValue()) { throw new Error("Organization ID mismatch"); } - const hecConfig = new HecConfiguration(url, bearerToken, service); - const updatedIntegrationResponse = - await this.integrationApiService.updateOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), - ); - - const updatedTemplate = new HecTemplate(index, service); - const updatedIntegrationConfigResponse = - await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - new OrganizationIntegrationConfigurationRequest( - null, - null, - null, - updatedTemplate.toString(), - ), + try { + const hecConfig = new HecConfiguration(url, bearerToken, service); + const updatedIntegrationResponse = + await this.integrationApiService.updateOrganizationIntegration( + organizationId, + OrganizationIntegrationId, + new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), + ); + + const updatedTemplate = new HecTemplate(index, service); + const updatedIntegrationConfigResponse = + await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( + organizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, + new OrganizationIntegrationConfigurationRequest( + null, + null, + null, + updatedTemplate.toString(), + ), + ); + + const updatedIntegration = this.mapResponsesToOrganizationIntegration( + updatedIntegrationResponse, + updatedIntegrationConfigResponse, ); - const updatedIntegration = this.mapResponsesToOrganizationIntegration( - updatedIntegrationResponse, - updatedIntegrationConfigResponse, - ); - - if (updatedIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); + if (updatedIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; } } @@ -160,28 +186,38 @@ export class HecOrganizationIntegrationService { organizationId: OrganizationId, OrganizationIntegrationId: OrganizationIntegrationId, OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, - ) { + ): Promise { if (organizationId != this.organizationId$.getValue()) { throw new Error("Organization ID mismatch"); } - // delete the configuration first due to foreign key constraint - await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - ); - // delete the integration - await this.integrationApiService.deleteOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - ); + try { + // delete the configuration first due to foreign key constraint + await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( + organizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, + ); - // update the local observable - const updatedIntegrations = this._integrations$ - .getValue() - .filter((i) => i.id !== OrganizationIntegrationId); - this._integrations$.next(updatedIntegrations); + // delete the integration + await this.integrationApiService.deleteOrganizationIntegration( + organizationId, + OrganizationIntegrationId, + ); + + // update the local observable + const updatedIntegrations = this._integrations$ + .getValue() + .filter((i) => i.id !== OrganizationIntegrationId); + this._integrations$.next(updatedIntegrations); + + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } } /** diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts index 17dac165baab..b6d2540d9d7a 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts @@ -10,14 +10,18 @@ export class OrganizationIntegrationApiService { async getOrganizationIntegrations( orgId: OrganizationId, ): Promise { - const response = await this.apiService.send( - "GET", - `/organizations/${orgId}/integrations`, - null, - true, - true, - ); - return response; + try { + const response = await this.apiService.send( + "GET", + `/organizations/${orgId}/integrations`, + null, + true, + true, + ); + return response; + } catch { + return []; + } } async createOrganizationIntegration( diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 0facf282ba32..74c396135020 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -314,7 +315,7 @@ describe("IntegrationCardComponent", () => { jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false); - mockIntegrationService.saveHec.mockResolvedValue(undefined); + mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -340,7 +341,7 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue(undefined); + mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -368,7 +369,7 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue(undefined); + mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -407,6 +408,52 @@ describe("IntegrationCardComponent", () => { }); }); + it("should show mustBeOwner toast on error while inserting data", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Edited, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); + + it("should show mustBeOwner toast on error while updating data", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Edited, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); + it("should show toast on error while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ @@ -429,5 +476,28 @@ describe("IntegrationCardComponent", () => { message: mockI18nService.t("failedToDeleteIntegration"), }); }); + + it("should show mustbeOwner toast on 404 while deleting", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Delete, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.deleteHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 99e8d950d81b..091de63d7a14 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -171,6 +171,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { + let saveResponse = { mustBeOwner: false, success: false }; if (this.isUpdateAvailable) { // retrieve org integration and configuration ids const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; @@ -182,7 +183,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } // update existing integration and configuration - await this.hecOrganizationIntegrationService.updateHec( + saveResponse = await this.hecOrganizationIntegrationService.updateHec( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, @@ -193,7 +194,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { ); } else { // create new integration and configuration - await this.hecOrganizationIntegrationService.saveHec( + saveResponse = await this.hecOrganizationIntegrationService.saveHec( this.organizationId, this.integrationSettings.name as OrganizationIntegrationServiceType, result.url, @@ -201,6 +202,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { result.index, ); } + + if (saveResponse.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + this.toastService.showToast({ variant: "success", title: "", @@ -217,16 +224,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { throw Error("Organization Integration ID or Configuration ID is missing"); } - await this.hecOrganizationIntegrationService.deleteHec( + const response = await this.hecOrganizationIntegrationService.deleteHec( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, ); + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + this.toastService.showToast({ variant: "success", title: "", message: this.i18nService.t("success"), }); } + + private showMustBeOwnerToast() { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("mustBeOrgOwnerToPerformAction"), + }); + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index e3af5e273eac..c249bf42282d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -5,16 +5,14 @@ import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { IntegrationType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -218,7 +216,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.organization$ = this.route.params.pipe( switchMap((params) => this.organizationService.organizations$(userId).pipe( - getOrganizationById(params.organizationId), + getById(params.organizationId), // Filter out undefined values takeWhile((org: Organization | undefined) => !!org), ), @@ -229,6 +227,24 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); }); + + // For all existing event based configurations loop through and assign the + // organizationIntegration for the correct services. + this.hecOrganizationIntegrationService.integrations$ + .pipe(takeUntil(this.destroy$)) + .subscribe((integrations) => { + // reset all integrations to null first - in case one was deleted + this.integrationsList.forEach((i) => { + i.organizationIntegration = null; + }); + + integrations.map((integration) => { + const item = this.integrationsList.find((i) => i.name === integration.serviceType); + if (item) { + item.organizationIntegration = integration; + } + }); + }); } constructor( @@ -258,24 +274,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(crowdstrikeIntegration); } - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.hecOrganizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); - if (item) { - item.organizationIntegration = integration; - } - }); - }); } ngOnDestroy(): void { this.destroy$.next();