From 4857855c119adc1208f7844afd7bc997a4fc5f85 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 11 Sep 2025 08:10:42 -0500 Subject: [PATCH] [PM-23824] Implement HEC integration (#16274) --- .../organization-routing.module.ts | 13 - .../shared/components/integrations/index.ts | 4 - apps/web/src/app/core/core.module.ts | 7 - apps/web/src/locales/en/messages.json | 12 + .../bit-common/src/dirt/integrations/index.ts | 6 - .../src/dirt/integrations/services/index.ts | 2 - .../models/configuration/hec-configuration.ts | 18 ++ .../configuration/webhook-configuration.ts | 14 + .../configuration-template/hec-template.ts | 17 ++ .../webhook-template.ts | 14 + ...ebhook-integration-configuration-config.ts | 13 + .../models/integration.ts | 8 +- ...ation-integration-configuration-request.ts | 0 ...tion-integration-configuration-response.ts | 18 +- .../organization-integration-configuration.ts | 41 +++ .../organization-integration-request.ts | 0 .../organization-integration-response.ts | 0 .../organization-integration-service-type.ts | 0 .../models/organization-integration-type.ts | 0 .../models/organization-integration.ts | 36 +++ ...c-organization-integration-service.spec.ts | 201 +++++++++++++ .../hec-organization-integration-service.ts | 284 ++++++++++++++++++ ...ganization-integration-api.service.spec.ts | 0 .../organization-integration-api.service.ts | 3 - ...egration-configuration-api.service.spec.ts | 0 ...n-integration-configuration-api.service.ts | 3 - .../organizations-routing.module.ts | 8 + .../integration-card.component.html | 9 +- .../integration-card.component.spec.ts | 187 ++++++++++-- .../integration-card.component.ts | 106 +++---- .../connect-dialog-hec.component.html | 6 +- .../connect-dialog-hec.component.spec.ts | 17 +- .../connect-dialog-hec.component.ts | 46 +-- .../integration-dialog/index.ts | 0 .../integration-grid.component.html | 1 - .../integration-grid.component.spec.ts | 18 +- .../integration-grid.component.ts | 8 +- .../integrations.component.html | 0 .../integrations.component.ts | 97 +++--- .../integrations.pipe.ts | 3 +- ...rganization-integrations-routing.module.ts | 23 ++ .../organization-integrations.module.ts | 32 ++ .../integrations.component.spec.ts | 13 +- .../integrations/integrations.component.ts | 2 +- .../integrations/integrations.module.ts | 5 +- 45 files changed, 1067 insertions(+), 228 deletions(-) delete mode 100644 apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/integrations/index.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/integrations/services/index.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/webhook-integration-configuration-config.ts rename apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts => bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts (65%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-configuration-request.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-configuration-response.ts (55%) create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-request.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-response.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-service-type.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/models/organization-integration-type.ts (100%) create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/services/organization-integration-api.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/services/organization-integration-api.service.ts (96%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/services/organization-integration-configuration-api.service.spec.ts (100%) rename bitwarden_license/bit-common/src/dirt/{integrations => organization-integrations}/services/organization-integration-configuration-api.service.ts (97%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-card/integration-card.component.html (85%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-card/integration-card.component.spec.ts (50%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-card/integration-card.component.ts (63%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-dialog/connect-dialog/connect-dialog-hec.component.html (91%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts (93%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-dialog/connect-dialog/connect-dialog-hec.component.ts (56%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-dialog/index.ts (100%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-grid/integration-grid.component.html (94%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-grid/integration-grid.component.spec.ts (86%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integration-grid/integration-grid.component.ts (70%) rename {apps/web/src/app/admin-console/organizations/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integrations.component.html (100%) rename {apps/web/src/app/admin-console/organizations/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integrations.component.ts (78%) rename {apps/web/src/app/admin-console/organizations/shared/components/integrations => bitwarden_license/bit-web/src/app/dirt/organization-integrations}/integrations.pipe.ts (78%) create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts diff --git a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts index ab32a0b1eef..230ddbdf860 100644 --- a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts @@ -19,7 +19,6 @@ import { deepLinkGuard } from "../../auth/guards/deep-link/deep-link.guard"; import { VaultModule } from "./collections/vault.module"; import { organizationPermissionsGuard } from "./guards/org-permissions.guard"; import { organizationRedirectGuard } from "./guards/org-redirect.guard"; -import { AdminConsoleIntegrationsComponent } from "./integrations/integrations.component"; import { OrganizationLayoutComponent } from "./layouts/organization-layout.component"; import { GroupsComponent } from "./manage/groups.component"; @@ -39,14 +38,6 @@ const routes: Routes = [ path: "vault", loadChildren: () => VaultModule, }, - { - path: "integrations", - canActivate: [organizationPermissionsGuard(canAccessIntegrations)], - component: AdminConsoleIntegrationsComponent, - data: { - titleId: "integrations", - }, - }, { path: "settings", loadChildren: () => @@ -103,10 +94,6 @@ function getOrganizationRoute(organization: Organization): string { return undefined; } -function canAccessIntegrations(organization: Organization) { - return organization.canAccessIntegrations; -} - @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts deleted file mode 100644 index c8fe9d32652..00000000000 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./integrations.pipe"; -export * from "./integration-card/integration-card.component"; -export * from "./integration-grid/integration-grid.component"; -export * from "./models"; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index b3ce39d5021..06c31a0bfd4 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -41,8 +41,6 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, } from "@bitwarden/auth/common"; -// eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -405,11 +403,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDeviceManagementComponentService, deps: [], }), - safeProvider({ - provide: OrganizationIntegrationApiService, - useClass: OrganizationIntegrationApiService, - deps: [ApiService], - }), ]; @NgModule({ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5223dff9b2d..01099c4af58 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7437,6 +7437,9 @@ "off": { "message": "Off" }, + "connected": { + "message": "Connected" + }, "members": { "message": "Members" }, @@ -9694,6 +9697,15 @@ } } }, + "updateIntegrationButtonDesc": { + "message": "Update $INTEGRATION$", + "placeholders": { + "integration": { + "content": "$1", + "example": "Crowdstrike" + } + } + }, "integrationCardTooltip": { "message": "Launch $INTEGRATION$ implementation guide.", "placeholders": { diff --git a/bitwarden_license/bit-common/src/dirt/integrations/index.ts b/bitwarden_license/bit-common/src/dirt/integrations/index.ts deleted file mode 100644 index d2c1d173e3c..00000000000 --- a/bitwarden_license/bit-common/src/dirt/integrations/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./services"; -export * from "./models/organization-integration-type"; -export * from "./models/organization-integration-request"; -export * from "./models/organization-integration-response"; -export * from "./models/organization-integration-configuration-request"; -export * from "./models/organization-integration-configuration-response"; diff --git a/bitwarden_license/bit-common/src/dirt/integrations/services/index.ts b/bitwarden_license/bit-common/src/dirt/integrations/services/index.ts deleted file mode 100644 index 68a673854ae..00000000000 --- a/bitwarden_license/bit-common/src/dirt/integrations/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./organization-integration-api.service"; -export * from "./organization-integration-configuration-api.service"; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts new file mode 100644 index 00000000000..cdb7a5f265a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -0,0 +1,18 @@ +import { OrganizationIntegrationServiceType } from "../organization-integration-service-type"; + +export class HecConfiguration { + uri: string; + scheme = "Bearer"; + token: string; + service: OrganizationIntegrationServiceType; + + constructor(uri: string, token: string, service: string) { + this.uri = uri; + this.token = token; + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts new file mode 100644 index 00000000000..a4dca7378ba --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts @@ -0,0 +1,14 @@ +// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration +export class WebhookConfiguration { + propA: string; + propB: string; + + constructor(propA: string, propB: string) { + this.propA = propA; + this.propB = propB; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts new file mode 100644 index 00000000000..7a841697fde --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -0,0 +1,17 @@ +import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type"; + +export class HecTemplate { + event = "#EventMessage#"; + source = "Bitwarden"; + index: string; + service: OrganizationIntegrationServiceType; + + constructor(index: string, service: string) { + this.index = index; + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts new file mode 100644 index 00000000000..7c51e98282b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts @@ -0,0 +1,14 @@ +// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration +export class WebhookTemplate { + propA: string; + propB: string; + + constructor(propA: string, propB: string) { + this.propA = propA; + this.propB = propB; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/webhook-integration-configuration-config.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/webhook-integration-configuration-config.ts new file mode 100644 index 00000000000..9ee72bfaa8b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/webhook-integration-configuration-config.ts @@ -0,0 +1,13 @@ +export class WebhookIntegrationConfigurationConfig { + propA: string; + propB: string; + + constructor(propA: string, propB: string) { + this.propA = propA; + this.propB = propB; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts similarity index 65% rename from apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts index b3d24ffb3b0..1bb38915e9a 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts @@ -1,4 +1,6 @@ -import { IntegrationType } from "@bitwarden/common/enums"; +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; + +import { OrganizationIntegration } from "./organization-integration"; /** Integration or SDK */ export type Integration = { @@ -21,4 +23,8 @@ export type Integration = { isConnected?: boolean; canSetupConnection?: boolean; configuration?: string; + template?: string; + + // OrganizationIntegration + organizationIntegration?: OrganizationIntegration | null; }; diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-request.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration-request.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-request.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration-request.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration-response.ts similarity index 55% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration-response.ts index 47baf3276ad..600edc8c7c9 100644 --- a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration-response.ts @@ -1,6 +1,9 @@ import { EventType } from "@bitwarden/common/enums"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; -import { OrganizationIntegrationConfigurationId } from "@bitwarden/common/types/guid"; +import { + OrganizationIntegrationConfigurationId, + OrganizationIntegrationId, +} from "@bitwarden/common/types/guid"; export class OrganizationIntegrationConfigurationResponse extends BaseResponse { id: OrganizationIntegrationConfigurationId; @@ -18,3 +21,16 @@ export class OrganizationIntegrationConfigurationResponse extends BaseResponse { this.template = this.getResponseProperty("Template"); } } + +export class OrganizationIntegrationConfigurationResponseWithIntegrationId { + integrationId: OrganizationIntegrationId; + configurationResponses: OrganizationIntegrationConfigurationResponse[]; + + constructor( + integrationId: OrganizationIntegrationId, + configurationResponses: OrganizationIntegrationConfigurationResponse[], + ) { + this.integrationId = integrationId; + this.configurationResponses = configurationResponses; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts new file mode 100644 index 00000000000..d4bbd30055f --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts @@ -0,0 +1,41 @@ +import { EventType } from "@bitwarden/common/enums"; +import { + OrganizationIntegrationConfigurationId, + OrganizationIntegrationId, +} from "@bitwarden/common/types/guid"; + +import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; +import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template"; +import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config"; + +export class OrganizationIntegrationConfiguration { + id: OrganizationIntegrationConfigurationId; + integrationId: OrganizationIntegrationId; + eventType?: EventType | null; + configuration?: WebhookIntegrationConfigurationConfig | null; + filters?: string; + template?: HecTemplate | WebhookTemplate | null; + + constructor( + id: OrganizationIntegrationConfigurationId, + integrationId: OrganizationIntegrationId, + eventType?: EventType | null, + configuration?: WebhookIntegrationConfigurationConfig | null, + filters?: string, + template?: HecTemplate | WebhookTemplate | null, + ) { + this.id = id; + this.integrationId = integrationId; + this.eventType = eventType; + this.configuration = configuration; + this.filters = filters; + this.template = template; + } + + getTemplate(): T | null { + if (this.template && typeof this.template === "object") { + return this.template as T; + } + return null; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-request.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-request.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-response.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-response.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-response.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-response.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-service-type.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-type.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts new file mode 100644 index 00000000000..abbe2271b30 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts @@ -0,0 +1,36 @@ +import { OrganizationIntegrationId } from "@bitwarden/common/types/guid"; + +import { HecConfiguration } from "./configuration/hec-configuration"; +import { WebhookConfiguration } from "./configuration/webhook-configuration"; +import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration"; +import { OrganizationIntegrationServiceType } from "./organization-integration-service-type"; +import { OrganizationIntegrationType } from "./organization-integration-type"; + +export class OrganizationIntegration { + id: OrganizationIntegrationId; + type: OrganizationIntegrationType; + serviceType: OrganizationIntegrationServiceType; + configuration: HecConfiguration | WebhookConfiguration | null; + integrationConfiguration: OrganizationIntegrationConfiguration[] = []; + + constructor( + id: OrganizationIntegrationId, + type: OrganizationIntegrationType, + serviceType: OrganizationIntegrationServiceType, + configuration: HecConfiguration | WebhookConfiguration | null, + integrationConfiguration: OrganizationIntegrationConfiguration[] = [], + ) { + this.id = id; + this.type = type; + this.serviceType = serviceType; + this.configuration = configuration; + this.integrationConfiguration = integrationConfiguration; + } + + getConfiguration(): T | null { + if (this.configuration && typeof this.configuration === "object") { + return this.configuration as T; + } + return null; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts new file mode 100644 index 00000000000..556078ea862 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts @@ -0,0 +1,201 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { + OrganizationId, + OrganizationIntegrationConfigurationId, + OrganizationIntegrationId, +} from "@bitwarden/common/types/guid"; + +import { HecConfiguration } from "../models/configuration/hec-configuration"; +import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegration } from "../models/organization-integration"; +import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { HecOrganizationIntegrationService } from "./hec-organization-integration-service"; +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; + +describe("HecOrganizationIntegrationService", () => { + let service: HecOrganizationIntegrationService; + const mockIntegrationApiService = mock(); + const mockIntegrationConfigurationApiService = + mock(); + const organizationId = "org-1" as OrganizationId; + const integrationId = "int-1" as OrganizationIntegrationId; + const configId = "conf-1" as OrganizationIntegrationConfigurationId; + const serviceType = OrganizationIntegrationServiceType.CrowdStrike; + const url = "https://example.com"; + const bearerToken = "token"; + const index = "main"; + + beforeEach(() => { + service = new HecOrganizationIntegrationService( + mockIntegrationApiService, + mockIntegrationConfigurationApiService, + ); + + jest.resetAllMocks(); + }); + + it("should set organization integrations", (done) => { + mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]); + service.setOrganizationIntegrations(organizationId); + const subscription = service.integrations$.subscribe((integrations) => { + expect(integrations).toEqual([]); + subscription.unsubscribe(); + done(); + }); + }); + + it("should save a new Hec integration", async () => { + service.setOrganizationIntegrations(organizationId); + + const integrationResponse = { + id: integrationId, + type: OrganizationIntegrationType.Hec, + configuration: JSON.stringify({ url, bearerToken, service: serviceType }), + } as OrganizationIntegrationResponse; + + const configResponse = { + id: configId, + template: JSON.stringify({ index, service: serviceType }), + } as OrganizationIntegrationConfigurationResponse; + + mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse); + mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue( + configResponse, + ); + + await service.saveHec(organizationId, serviceType, url, bearerToken, index); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations.length).toBe(1); + expect(integrations[0].id).toBe(integrationId); + expect(integrations[0].serviceType).toBe(serviceType); + }); + + it("should throw error on organization ID mismatch in saveHec", async () => { + service.setOrganizationIntegrations("other-org" as OrganizationId); + await expect( + service.saveHec(organizationId, serviceType, url, bearerToken, index), + ).rejects.toThrow(Error("Organization ID mismatch")); + }); + + it("should update an existing Hec integration", async () => { + service.setOrganizationIntegrations(organizationId); + + const integrationResponse = { + id: integrationId, + type: OrganizationIntegrationType.Hec, + configuration: JSON.stringify({ url, bearerToken, service: serviceType }), + } as OrganizationIntegrationResponse; + + const configResponse = { + id: configId, + template: JSON.stringify({ index, service: serviceType }), + } as OrganizationIntegrationConfigurationResponse; + + mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse); + mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( + configResponse, + ); + + await service.updateHec( + organizationId, + integrationId, + configId, + serviceType, + url, + bearerToken, + index, + ); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations.length).toBe(1); + expect(integrations[0].id).toBe(integrationId); + }); + + it("should throw error on organization ID mismatch in updateHec", async () => { + service.setOrganizationIntegrations("other-org" as OrganizationId); + await expect( + service.updateHec( + organizationId, + integrationId, + configId, + serviceType, + url, + bearerToken, + index, + ), + ).rejects.toThrow(Error("Organization ID mismatch")); + }); + + it("should get integration by id", async () => { + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Hec, + serviceType, + {} as HecConfiguration, + [], + ), + ]); + const integration = await service.getIntegrationById(integrationId); + expect(integration).not.toBeNull(); + expect(integration!.id).toBe(integrationId); + }); + + it("should get integration by service type", async () => { + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Hec, + serviceType, + {} as HecConfiguration, + [], + ), + ]); + const integration = await service.getIntegrationByServiceType(serviceType); + expect(integration).not.toBeNull(); + expect(integration!.serviceType).toBe(serviceType); + }); + + it("should get integration configurations", async () => { + const config = new OrganizationIntegrationConfiguration( + configId, + integrationId, + null, + null, + "", + {} as HecTemplate, + ); + + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Hec, + serviceType, + {} as HecConfiguration, + [config], + ), + ]); + const configs = await service.getIntegrationConfigurations(integrationId); + expect(configs).not.toBeNull(); + expect(configs![0].id).toBe(configId); + }); + + it("convertToJson should parse valid JSON", () => { + const obj = service.convertToJson<{ a: number }>('{"a":1}'); + expect(obj).toEqual({ a: 1 }); + }); + + it("convertToJson should return null for invalid JSON", () => { + const obj = service.convertToJson<{ a: number }>("invalid"); + expect(obj).toBeNull(); + }); +}); 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 new file mode 100644 index 00000000000..d45d4d21652 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts @@ -0,0 +1,284 @@ +import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; + +import { + OrganizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, +} from "@bitwarden/common/types/guid"; + +import { HecConfiguration } from "../models/configuration/hec-configuration"; +import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegration } from "../models/organization-integration"; +import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; +import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; + +export class HecOrganizationIntegrationService { + private organizationId$ = new BehaviorSubject(null); + private _integrations$ = new BehaviorSubject([]); + private destroy$ = new Subject(); + + integrations$ = this._integrations$.asObservable(); + + private fetch$ = this.organizationId$ + .pipe( + switchMap(async (orgId) => { + if (orgId) { + const data$ = await this.setIntegrations(orgId); + return await firstValueFrom(data$); + } else { + return this._integrations$.getValue(); + } + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: (integrations) => { + this._integrations$.next(integrations); + }, + }); + + constructor( + private integrationApiService: OrganizationIntegrationApiService, + private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, + ) {} + + /** + * Sets the organization Id and will trigger the retrieval of the + * integrations for a given org. + * @param orgId + */ + setOrganizationIntegrations(orgId: OrganizationId) { + this.organizationId$.next(orgId); + } + + /** + * Saves a new organization integration and updates the integrations$ observable + * @param organizationId id of the organization + * @param service service type of the integration + * @param url url of the service + * @param bearerToken api token + * @param index index in service + */ + async saveHec( + organizationId: OrganizationId, + service: OrganizationIntegrationServiceType, + url: string, + bearerToken: string, + index: string, + ) { + 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( + 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]); + } + } + + /** + * Updates an existing organization integration and updates the integrations$ observable + * @param organizationId id of the organization + * @param OrganizationIntegrationId id of the organization integration + * @param OrganizationIntegrationConfigurationId id of the organization integration configuration + * @param service service type of the integration + * @param url url of the service + * @param bearerToken api token + * @param index index in service + */ + async updateHec( + organizationId: OrganizationId, + OrganizationIntegrationId: OrganizationIntegrationId, + OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, + service: OrganizationIntegrationServiceType, + url: string, + bearerToken: string, + index: string, + ) { + 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(), + ), + ); + + const updatedIntegration = this.mapResponsesToOrganizationIntegration( + updatedIntegrationResponse, + updatedIntegrationConfigResponse, + ); + + if (updatedIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); + } + } + + /** + * Gets a OrganizationIntegration for an OrganizationIntegrationId + * @param integrationId id of the integration + * @returns OrganizationIntegration or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationById( + integrationId: OrganizationIntegrationId, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => integrations.find((i) => i.id === integrationId) || null), + ), + ); + } + + /** + * Gets a OrganizationIntegration for a service type + * @param serviceType type of the service + * @returns OrganizationIntegration or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationByServiceType( + serviceType: OrganizationIntegrationServiceType, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null), + ), + ); + } + + /** + * Gets a OrganizationIntegrationConfigurations for an integration ID + * @param integrationId id of the integration + * @returns OrganizationIntegration array or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationConfigurations( + integrationId: OrganizationIntegrationId, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => { + const integration = integrations.find((i) => i.id === integrationId); + return integration ? integration.integrationConfiguration : null; + }), + ), + ); + } + + // TODO: Move to data models to be more explicit for future services + private mapResponsesToOrganizationIntegration( + integrationResponse: OrganizationIntegrationResponse, + configurationResponse: OrganizationIntegrationConfigurationResponse, + ): OrganizationIntegration | null { + const hecConfig = this.convertToJson(integrationResponse.configuration); + const template = this.convertToJson(configurationResponse.template); + + if (!hecConfig || !template) { + return null; + } + + const integrationConfig = new OrganizationIntegrationConfiguration( + configurationResponse.id, + integrationResponse.id, + null, + null, + "", + template, + ); + + return new OrganizationIntegration( + integrationResponse.id, + integrationResponse.type, + hecConfig.service, + hecConfig, + [integrationConfig], + ); + } + + // Could possibly be moved to a base service. All services would then assume that the + // integration configuration would always be an array and this hec specific service + // would just assume a single entry. + private setIntegrations(orgId: OrganizationId) { + const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( + switchMap(([responses]) => { + const integrations: OrganizationIntegration[] = []; + const promises: Promise[] = []; + + responses.forEach((integration) => { + const promise = this.integrationConfigurationApiService + .getOrganizationIntegrationConfigurations(orgId, integration.id) + .then((response) => { + // Hec events will only have one OrganizationIntegrationConfiguration + const config = response[0]; + + const orgIntegration = this.mapResponsesToOrganizationIntegration( + integration, + config, + ); + + if (orgIntegration !== null) { + integrations.push(orgIntegration); + } + }); + promises.push(promise); + }); + return Promise.all(promises).then(() => { + return integrations; + }); + }), + ); + + return results$; + } + + // TODO: Move to base service when necessary + convertToJson(jsonString?: string): T | null { + try { + return JSON.parse(jsonString || "") as T; + } catch { + return null; + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-api.service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts similarity index 96% rename from bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-api.service.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts index 2c2266940e0..17dac165baa 100644 --- a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.ts @@ -1,12 +1,9 @@ -import { Injectable } from "@angular/core"; - import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid"; import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; -@Injectable() export class OrganizationIntegrationApiService { constructor(private apiService: ApiService) {} diff --git a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-configuration-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-configuration-api.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-configuration-api.service.spec.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-configuration-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-configuration-api.service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-configuration-api.service.ts similarity index 97% rename from bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-configuration-api.service.ts rename to bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-configuration-api.service.ts index b5bac73b280..fda4ac19263 100644 --- a/bitwarden_license/bit-common/src/dirt/integrations/services/organization-integration-configuration-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-configuration-api.service.ts @@ -1,5 +1,3 @@ -import { Injectable } from "@angular/core"; - import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationId, @@ -10,7 +8,6 @@ import { import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; -@Injectable() export class OrganizationIntegrationConfigurationApiService { constructor(private apiService: ApiService) {} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts index 35659d05dce..0ee78e59312 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts @@ -85,6 +85,14 @@ const routes: Routes = [ (m) => m.AccessIntelligenceModule, ), }, + { + path: "integrations", + canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)], + loadChildren: () => + import("../../dirt/organization-integrations/organization-integrations.module").then( + (m) => m.OrganizationIntegrationsModule, + ), + }, ], }, ]; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html similarity index 85% rename from apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.html rename to bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html index e5687c71ed9..8d077c4b502 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html @@ -22,7 +22,7 @@ @if (showConnectedBadge()) { @if (isConnected) { - {{ "on" | i18n }} + {{ "connected" | i18n }} } @if (!isConnected) { {{ "off" | i18n }} @@ -34,7 +34,11 @@ @if (canSetupConnection) { } @@ -44,6 +48,7 @@ [href]="linkURL" rel="noopener noreferrer" target="_blank" + title="{{ linkURL }}" > } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/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 similarity index 50% rename from apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts rename to bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 382d245b235..c7a7ff45761 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/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 @@ -1,27 +1,34 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -// eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; +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 { 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"; -import { ToastService } from "@bitwarden/components"; -// eslint-disable-next-line no-restricted-imports -import { SharedModule } from "@bitwarden/components/src/shared"; +import { DialogService, ToastService } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { openHecConnectDialog } from "../integration-dialog"; import { IntegrationCardComponent } from "./integration-card.component"; +jest.mock("../integration-dialog", () => ({ + openHecConnectDialog: jest.fn(), +})); + describe("IntegrationCardComponent", () => { let component: IntegrationCardComponent; let fixture: ComponentFixture; const mockI18nService = mock(); const activatedRoute = mock(); - const mockOrgIntegrationApiService = mock(); + const mockIntegrationService = mock(); + const dialogService = mock(); + const toastService = mock(); const systemTheme$ = new BehaviorSubject(ThemeType.Light); const usersPreferenceTheme$ = new BehaviorSubject(ThemeType.Light); @@ -43,8 +50,9 @@ describe("IntegrationCardComponent", () => { { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, - { provide: ToastService, useValue: mock() }, + { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, + { provide: ToastService, useValue: toastService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); }); @@ -186,27 +194,160 @@ describe("IntegrationCardComponent", () => { }); }); - describe("connected badge", () => { - it("shows connected badge when isConnected is true", () => { - component.isConnected = true; + describe("showNewBadge", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-06-01")); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it("returns false when newBadgeExpiration is undefined", () => { + component.newBadgeExpiration = undefined; + expect(component.showNewBadge()).toBe(false); + }); + + it("returns false when newBadgeExpiration is an invalid date", () => { + component.newBadgeExpiration = "invalid-date"; + expect(component.showNewBadge()).toBe(false); + }); + + it("returns true when newBadgeExpiration is in the future", () => { + component.newBadgeExpiration = "2024-06-02"; + expect(component.showNewBadge()).toBe(true); + }); + + it("returns false when newBadgeExpiration is today", () => { + component.newBadgeExpiration = "2024-06-01"; + expect(component.showNewBadge()).toBe(false); + }); + + it("returns false when newBadgeExpiration is in the past", () => { + component.newBadgeExpiration = "2024-05-31"; + expect(component.showNewBadge()).toBe(false); + }); + }); + describe("showConnectedBadge", () => { + it("returns true when canSetupConnection is true", () => { + component.canSetupConnection = true; expect(component.showConnectedBadge()).toBe(true); }); - it("does not show connected badge when isConnected is false", () => { - component.isConnected = false; - fixture.detectChanges(); - const name = fixture.nativeElement.querySelector("h3 > span > span > span"); - - expect(name.textContent).toContain("off"); - // when isConnected is true/false, the badge should be shown as on/off - // when isConnected is undefined, the badge should not be shown - expect(component.showConnectedBadge()).toBe(true); + it("returns false when canSetupConnection is false", () => { + component.canSetupConnection = false; + expect(component.showConnectedBadge()).toBe(false); }); - it("does not show connected badge when isConnected is undefined", () => { - component.isConnected = undefined; + it("returns false when canSetupConnection is undefined", () => { + component.canSetupConnection = undefined; expect(component.showConnectedBadge()).toBe(false); }); }); + + describe("setupConnection", () => { + beforeEach(() => { + component.integrationSettings = { + organizationIntegration: { + id: "integration-id", + configuration: {}, + integrationConfiguration: [{ id: "config-id" }], + }, + name: OrganizationIntegrationServiceType.CrowdStrike, + } as any; + component.organizationId = "org-id" as any; + jest.resetAllMocks(); + }); + + it("should not proceed if dialog is cancelled", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ success: false }), + }); + await component.setupConnection(); + expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.saveHec).not.toHaveBeenCalled(); + }); + + it("should call updateHec if isUpdateAvailable is true", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: true, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalledWith( + "org-id", + "integration-id", + "config-id", + OrganizationIntegrationServiceType.CrowdStrike, + "test-url", + "token", + "index", + ); + expect(mockIntegrationService.saveHec).not.toHaveBeenCalled(); + }); + + it("should call saveHec if isUpdateAvailable is false", async () => { + component.integrationSettings = { + organizationIntegration: null, + name: OrganizationIntegrationServiceType.CrowdStrike, + } as any; + component.organizationId = "org-id" as any; + + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: true, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false); + + mockIntegrationService.saveHec.mockResolvedValue(undefined); + + await component.setupConnection(); + + expect(mockIntegrationService.saveHec).toHaveBeenCalledWith( + "org-id", + OrganizationIntegrationServiceType.CrowdStrike, + "test-url", + "token", + "index", + ); + expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); + }); + + it("should show toast on error", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: true, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.updateHec.mockRejectedValue(new Error("fail")); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("failedToSaveIntegration"), + }); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts similarity index 63% rename from apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts rename to bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 1d95d3182b2..f40fb03c5f4 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AfterViewInit, Component, @@ -13,22 +11,17 @@ import { ActivatedRoute } from "@angular/router"; import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -// eslint-disable-next-line no-restricted-imports -import { - OrganizationIntegrationType, - OrganizationIntegrationRequest, - OrganizationIntegrationResponse, - OrganizationIntegrationApiService, -} from "@bitwarden/bit-common/dirt/integrations/index"; +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 { 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"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { SharedModule } from "../../../../../../shared/shared.module"; import { openHecConnectDialog } from "../integration-dialog/index"; -import { Integration } from "../models"; @Component({ selector: "app-integration-card", @@ -37,13 +30,13 @@ import { Integration } from "../models"; }) export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private destroyed$: Subject = new Subject(); - @ViewChild("imageEle") imageEle: ElementRef; + @ViewChild("imageEle") imageEle!: ElementRef; - @Input() name: string; - @Input() image: string; - @Input() imageDarkMode?: string; - @Input() linkURL: string; - @Input() integrationSettings: Integration; + @Input() name: string = ""; + @Input() image: string = ""; + @Input() imageDarkMode: string = ""; + @Input() linkURL: string = ""; + @Input() integrationSettings!: Integration; /** Adds relevant `rel` attribute to external links */ @Input() externalURL?: boolean; @@ -56,19 +49,24 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { */ @Input() newBadgeExpiration?: string; @Input() description?: string; - @Input() isConnected?: boolean; @Input() canSetupConnection?: boolean; + organizationId: OrganizationId; + constructor( private themeStateService: ThemeStateService, @Inject(SYSTEM_THEME_OBSERVABLE) private systemTheme$: Observable, private dialogService: DialogService, private activatedRoute: ActivatedRoute, - private apiService: OrganizationIntegrationApiService, + private hecOrganizationIntegrationService: HecOrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, - ) {} + ) { + this.organizationId = this.activatedRoute.snapshot.paramMap.get( + "organizationId", + ) as OrganizationId; + } ngAfterViewInit() { combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$]) @@ -116,8 +114,16 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return expirationDate > new Date(); } + get isConnected(): boolean { + return !!this.integrationSettings.organizationIntegration?.configuration; + } + showConnectedBadge(): boolean { - return this.isConnected !== undefined; + return this.canSetupConnection ?? false; + } + + get isUpdateAvailable(): boolean { + return !!this.integrationSettings.organizationIntegration; } async setupConnection() { @@ -135,43 +141,41 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return; } - // save the integration try { - const dbResponse = await this.saveHecIntegration(result.configuration); - this.isConnected = !!dbResponse.id; + if (this.isUpdateAvailable) { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + await this.hecOrganizationIntegrationService.updateHec( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.bearerToken, + result.index, + ); + } else { + await this.hecOrganizationIntegrationService.saveHec( + this.organizationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.bearerToken, + result.index, + ); + } } catch { this.toastService.showToast({ variant: "error", - title: null, + title: "", message: this.i18nService.t("failedToSaveIntegration"), }); return; } } - - async saveHecIntegration(configuration: string): Promise { - const organizationId = this.activatedRoute.snapshot.paramMap.get( - "organizationId", - ) as OrganizationId; - - const request = new OrganizationIntegrationRequest( - OrganizationIntegrationType.Hec, - configuration, - ); - - const integrations = await this.apiService.getOrganizationIntegrations(organizationId); - const existingIntegration = integrations.find( - (i) => i.type === OrganizationIntegrationType.Hec, - ); - - if (existingIntegration) { - return await this.apiService.updateOrganizationIntegration( - organizationId, - existingIntegration.id, - request, - ); - } else { - return await this.apiService.createOrganizationIntegration(organizationId, request); - } - } } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html similarity index 91% rename from apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html rename to bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html index 7f28317dd67..2495feacf60 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -28,7 +28,11 @@