1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-23824] Implement HEC integration (#16274)

This commit is contained in:
Vijay Oommen
2025-09-11 08:10:42 -05:00
committed by GitHub
parent afe3cbd78f
commit 4857855c11
45 changed files with 1067 additions and 228 deletions

View File

@@ -1,69 +0,0 @@
<app-header> </app-header>
<bit-tab-group [(selectedIndex)]="tabIndex" *ngIf="organization$ | async as organization">
<bit-tab [label]="'singleSignOn' | i18n" *ngIf="organization.useSso">
<section class="tw-mb-9">
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
<p bitTypography="body1">
{{ "ssoDescStart" | i18n }}
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
{{ "ssoDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab
[label]="'userProvisioning' | i18n"
*ngIf="organization.useScim || organization.useDirectory"
>
<section class="tw-mb-9" *ngIf="organization.useScim">
<h2 bitTypography="h2">
{{ "scimIntegration" | i18n }}
</h2>
<p bitTypography="body1">
{{ "scimIntegrationDescStart" | i18n }}
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
{{ "scimIntegrationDescEnd" | i18n }}
</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
></app-integration-grid>
</section>
<section class="tw-mb-9" *ngIf="organization.useDirectory">
<h2 bitTypography="h2">
{{ "bwdc" | i18n }}
</h2>
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab [label]="'eventManagement' | i18n" *ngIf="organization.useEvents">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "eventManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
></app-integration-grid>
</section>
</bit-tab>
<bit-tab [label]="'deviceManagement' | i18n">
<section class="tw-mb-9">
<h2 bitTypography="h2">
{{ "deviceManagement" | i18n }}
</h2>
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
<app-integration-grid
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
></app-integration-grid>
</section>
</bit-tab>
</bit-tab-group>

View File

@@ -1,290 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import {
getOrganizationById,
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 { 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 { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { SharedOrganizationModule } from "../shared";
import { IntegrationGridComponent } from "../shared/components/integrations/integration-grid/integration-grid.component";
import { FilterIntegrationsPipe } from "../shared/components/integrations/integrations.pipe";
import { Integration } from "../shared/components/integrations/models";
@Component({
selector: "ac-integrations",
templateUrl: "./integrations.component.html",
imports: [
SharedModule,
SharedOrganizationModule,
IntegrationGridComponent,
HeaderModule,
FilterIntegrationsPipe,
],
})
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// integrationsList: Integration[] = [];
tabIndex: number;
organization$: Observable<Organization>;
isEventBasedIntegrationsEnabled: boolean = false;
private destroy$ = new Subject<void>();
// initialize the integrations list with default integrations
integrationsList: Integration[] = [
{
name: "AD FS",
linkURL: "https://bitwarden.com/help/saml-adfs/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.SSO,
},
{
name: "Auth0",
linkURL: "https://bitwarden.com/help/saml-auth0/",
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "AWS",
linkURL: "https://bitwarden.com/help/saml-aws/",
image: "../../../../../../../images/integrations/aws-color.svg",
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/saml-azure/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SSO,
},
{
name: "Duo",
linkURL: "https://bitwarden.com/help/saml-duo/",
image: "../../../../../../../images/integrations/logo-duo-color.svg",
type: IntegrationType.SSO,
},
{
name: "Google",
linkURL: "https://bitwarden.com/help/saml-google/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "KeyCloak",
linkURL: "https://bitwarden.com/help/saml-keycloak/",
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
type: IntegrationType.SSO,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/saml-okta/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/saml-onelogin/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SSO,
},
{
name: "PingFederate",
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SSO,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "JumpCloud",
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
type: IntegrationType.SCIM,
},
{
name: "Ping Identity",
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
type: IntegrationType.SCIM,
},
{
name: "Active Directory",
linkURL: "https://bitwarden.com/help/ldap-directory/",
image: "../../../../../../../images/integrations/azure-active-directory.svg",
type: IntegrationType.BWDC,
},
{
name: "Microsoft Entra ID",
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Google Workspace",
linkURL: "https://bitwarden.com/help/workspace-directory/",
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
type: IntegrationType.BWDC,
},
{
name: "Okta",
linkURL: "https://bitwarden.com/help/okta-directory/",
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "OneLogin",
linkURL: "https://bitwarden.com/help/onelogin-directory/",
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
type: IntegrationType.BWDC,
},
{
name: "Splunk",
linkURL: "https://bitwarden.com/help/splunk-siem/",
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Sentinel",
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Rapid7",
linkURL: "https://bitwarden.com/help/rapid7-siem/",
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
type: IntegrationType.EVENT,
},
{
name: "Elastic",
linkURL: "https://bitwarden.com/help/elastic-siem/",
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Panther",
linkURL: "https://bitwarden.com/help/panther-siem/",
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
type: IntegrationType.EVENT,
},
{
name: "Microsoft Intune",
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
type: IntegrationType.DEVICE,
},
];
ngOnInit(): void {
const orgId = this.route.snapshot.params.organizationId;
this.organization$ = this.route.params.pipe(
switchMap((params) =>
this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.organizationService
.organizations$(account?.id)
.pipe(getOrganizationById(params.organizationId)),
),
),
),
);
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler)
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
// Update the integrations list with the fetched integrations
if (integrations && integrations.length > 0) {
integrations.forEach((integration) => {
const configJson = JSON.parse(integration.configuration || "{}");
const serviceName = configJson.service ?? "";
const existingIntegration = this.integrationsList.find((i) => i.name === serviceName);
if (existingIntegration) {
// if a configuration exists, then it is connected
existingIntegration.isConnected = !!integration.configuration;
existingIntegration.configuration = integration.configuration || "";
}
});
}
});
}
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private orgIntegrationApiService: OrganizationIntegrationApiService,
) {
this.configService
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEventBasedIntegrationsEnabled = isEnabled;
});
if (this.isEventBasedIntegrationsEnabled) {
this.integrationsList.push({
name: "Crowdstrike",
linkURL: "",
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT,
description: "crowdstrikeEventIntegrationDesc",
isConnected: false,
canSetupConnection: true,
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get IntegrationType(): typeof IntegrationType {
return IntegrationType;
}
}

View File

@@ -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],

View File

@@ -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";

View File

@@ -1,56 +0,0 @@
<div
class="tw-block tw-h-full tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-600 tw-relative tw-transition-all hover:tw-scale-105 focus-within:tw-outline-none focus-within:tw-ring focus-within:tw-ring-primary-700 focus-within:tw-ring-offset-2"
>
<div class="tw-flex tw-bg-secondary-100 tw-items-center tw-justify-end tw-pt-4 tw-pr-4">
<i class="bwi bwi-external-link"></i>
</div>
<div
class="tw-flex tw-h-32 tw-bg-secondary-100 tw-items-center tw-justify-center tw-pb-2 tw-px-6 lg:tw-pb-4 lg:tw-px-12"
>
<div class="tw-flex tw-items-center tw-justify-center tw-size-28 lg:tw-w-40">
<img
#imageEle
[src]="image"
alt=""
class="tw-block tw-mx-auto tw-h-auto tw-max-w-full tw-max-h-full"
/>
</div>
</div>
<div class="tw-p-5">
<h3 class="tw-text-main tw-text-lg tw-font-semibold">
{{ name }}
@if (showConnectedBadge()) {
<span class="tw-ml-3">
@if (isConnected) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
@if (!isConnected) {
<span bitBadge>{{ "off" | i18n }}</span>
}
</span>
}
</h3>
<p class="tw-mb-0">{{ description }}</p>
@if (canSetupConnection) {
<button type="button" class="tw-mt-3" bitButton (click)="setupConnection()">
<span>{{ "connectIntegrationButtonDesc" | i18n: name }}</span>
</button>
}
@if (linkURL) {
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-size-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
}
@if (showNewBadge()) {
<span bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
}
</div>
</div>

View File

@@ -1,212 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } 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 { 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 { I18nPipe } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
beforeEach(async () => {
// reset system theme
systemTheme$.next(ThemeType.Light);
activatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
await TestBed.configureTestingModule({
imports: [IntegrationCardComponent, SharedModule],
providers: [
{ provide: ThemeStateService, useValue: { selectedTheme$: usersPreferenceTheme$ } },
{ provide: SYSTEM_THEME_OBSERVABLE, useValue: systemTheme$ },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(IntegrationCardComponent);
component = fixture.componentInstance;
component.name = "Integration Name";
component.image = "test-image.png";
component.linkURL = "https://example.com/";
mockI18nService.t.mockImplementation((key) => key);
fixture.detectChanges();
});
it("assigns link href", () => {
const link = fixture.nativeElement.querySelector("a");
expect(link.href).toBe("https://example.com/");
});
it("renders card body", () => {
const name = fixture.nativeElement.querySelector("h3");
expect(name.textContent).toContain("Integration Name");
});
it("assigns external rel attribute", () => {
component.externalURL = true;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
expect(link.rel).toBe("noopener noreferrer");
});
describe("new badge", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2023-09-01"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows when expiration is in the future", () => {
component.newBadgeExpiration = "2023-09-02";
expect(component.showNewBadge()).toBe(true);
});
it("does not show when expiration is not set", () => {
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is in the past", () => {
component.newBadgeExpiration = "2023-08-31";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is today", () => {
component.newBadgeExpiration = "2023-09-01";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is invalid", () => {
component.newBadgeExpiration = "not-a-date";
expect(component.showNewBadge()).toBe(false);
});
});
describe("imageDarkMode", () => {
it("ignores theme changes when darkModeImage is not set", () => {
systemTheme$.next(ThemeType.Dark);
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
describe("user prefers the system theme", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("sets image src to imageDarkMode", () => {
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
it("sets image src to light mode image", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
describe("user prefers dark mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to dark mode", () => {
systemTheme$.next(ThemeType.Light); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
});
describe("user prefers light mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to light mode", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
systemTheme$.next(ThemeType.Dark); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
});
describe("connected badge", () => {
it("shows connected badge when isConnected is true", () => {
component.isConnected = 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("does not show connected badge when isConnected is undefined", () => {
component.isConnected = undefined;
expect(component.showConnectedBadge()).toBe(false);
});
});
});

View File

@@ -1,177 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
AfterViewInit,
Component,
ElementRef,
Inject,
Input,
OnDestroy,
ViewChild,
} from "@angular/core";
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 { 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 "../../../../../../shared/shared.module";
import { openHecConnectDialog } from "../integration-dialog/index";
import { Integration } from "../models";
@Component({
selector: "app-integration-card",
templateUrl: "./integration-card.component.html",
imports: [SharedModule],
})
export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
private destroyed$: Subject<void> = new Subject();
@ViewChild("imageEle") imageEle: ElementRef<HTMLImageElement>;
@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;
/**
* Date of when the new badge should be hidden.
* When omitted, the new badge is never shown.
*
* @example "2024-12-31"
*/
@Input() newBadgeExpiration?: string;
@Input() description?: string;
@Input() isConnected?: boolean;
@Input() canSetupConnection?: boolean;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService,
private activatedRoute: ActivatedRoute,
private apiService: OrganizationIntegrationApiService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngAfterViewInit() {
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
.pipe(takeUntil(this.destroyed$))
.subscribe(([theme, systemTheme]) => {
// When the card doesn't have a dark mode image, exit early
if (!this.imageDarkMode) {
return;
}
if (theme === ThemeType.System) {
// When the user's preference is the system theme,
// use the system theme to determine the image
const prefersDarkMode = systemTheme === ThemeType.Dark;
this.imageEle.nativeElement.src = prefersDarkMode ? this.imageDarkMode : this.image;
} else if (theme === ThemeType.Dark) {
// When the user's preference is dark mode, use the dark mode image
this.imageEle.nativeElement.src = this.imageDarkMode;
} else {
// Otherwise use the light mode image
this.imageEle.nativeElement.src = this.image;
}
});
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
/** Show the "new" badge when expiration is in the future */
showNewBadge() {
if (!this.newBadgeExpiration) {
return false;
}
const expirationDate = new Date(this.newBadgeExpiration);
// Do not show the new badge for invalid dates
if (isNaN(expirationDate.getTime())) {
return false;
}
return expirationDate > new Date();
}
showConnectedBadge(): boolean {
return this.isConnected !== undefined;
}
async setupConnection() {
// invoke the dialog to connect the integration
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
});
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
}
// save the integration
try {
const dbResponse = await this.saveHecIntegration(result.configuration);
this.isConnected = !!dbResponse.id;
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("failedToSaveIntegration"),
});
return;
}
}
async saveHecIntegration(configuration: string): Promise<OrganizationIntegrationResponse> {
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);
}
}
}

View File

@@ -1,38 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }}
</span>
<div bitDialogContent class="tw-flex tw-flex-col tw-gap-4">
@if (loading) {
<ng-container #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
}
@if (!loading) {
<ng-container>
<bit-form-field>
<bit-label>{{ "url" | i18n }}</bit-label>
<input bitInput formControlName="url" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "bearerToken" | i18n }}</bit-label>
<input bitInput formControlName="bearerToken" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "index" | i18n }}</bit-label>
<input bitInput formControlName="index" />
</bit-form-field>
</ng-container>
}
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,176 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
beforeAll(() => {
// Mock element.animate for jsdom
// the animate function is not available in jsdom, so we provide a mock implementation
// This is necessary for tests that rely on animations
// This mock does not perform any actual animations, it just provides a structure that allows tests
// to run without throwing errors related to missing animate function
if (!HTMLElement.prototype.animate) {
HTMLElement.prototype.animate = function () {
return {
play: () => {},
pause: () => {},
finish: () => {},
cancel: () => {},
reverse: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onfinish: null,
oncancel: null,
startTime: 0,
currentTime: 0,
playbackRate: 1,
playState: "idle",
replaceState: "active",
effect: null,
finished: Promise.resolve(),
id: "",
remove: () => {},
timeline: null,
ready: Promise.resolve(),
} as unknown as Animation;
};
}
});
describe("ConnectDialogHecComponent", () => {
let component: ConnectHecDialogComponent;
let fixture: ComponentFixture<ConnectHecDialogComponent>;
let dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
const mockI18nService = mock<I18nService>();
const integrationMock: Integration = {
name: "Test Integration",
image: "test-image.png",
linkURL: "https://example.com",
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
isConnected: false,
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
const connectInfo: HecConnectDialogParams = { settings: integrationMock };
beforeEach(async () => {
dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: connectInfo },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConnectHecDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
mockI18nService.t.mockImplementation((key) => key);
});
it("should create the component", () => {
expect(component).toBeTruthy();
});
it("should initialize form with empty values", () => {
expect(component.formGroup.value).toEqual({
url: "",
bearerToken: "",
index: "",
service: "Test Integration",
});
});
it("should have required validators for all fields", () => {
component.formGroup.setValue({ url: "", bearerToken: "", index: "", service: "" });
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should invalidate url if not matching pattern", () => {
component.formGroup.setValue({
url: "ftp://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should call dialogRef.close with correct result on submit", async () => {
component.formGroup.setValue({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
});
await component.submit();
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
configuration: JSON.stringify({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
}),
success: true,
error: null,
});
});
});
describe("openCrowdstrikeConnectDialog", () => {
it("should call dialogService.open with correct params", () => {
const dialogServiceMock = mock<DialogService>();
const config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>> = {
data: { settings: { name: "Test" } as Integration },
} as any;
openHecConnectDialog(dialogServiceMock, config);
expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHecDialogComponent, config);
});
});

View File

@@ -1,81 +0,0 @@
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
export type HecConnectDialogParams = {
settings: Integration;
};
export interface HecConnectDialogResult {
integrationSettings: Integration;
configuration: string;
success: boolean;
error: string | null;
}
@Component({
templateUrl: "./connect-dialog-hec.component.html",
imports: [SharedModule],
})
export class ConnectHecDialogComponent implements OnInit {
loading = false;
formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.pattern("https?://.+")]],
bearerToken: ["", Validators.required],
index: ["", Validators.required],
service: ["", Validators.required],
});
constructor(
@Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HecConnectDialogResult>,
) {}
ngOnInit(): void {
const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? "");
if (settings) {
this.formGroup.patchValue({
url: settings?.url || "",
bearerToken: settings?.bearerToken || "",
index: settings?.index || "",
service: this.connectInfo.settings.name,
});
}
}
getSettingsAsJson(configuration: string) {
try {
return JSON.parse(configuration);
} catch {
return {};
}
}
submit = async (): Promise<void> => {
const formJson = this.formGroup.getRawValue();
const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings,
configuration: JSON.stringify(formJson),
success: true,
error: null,
};
this.dialogRef.close(result);
return;
};
}
export function openHecConnectDialog(
dialogService: DialogService,
config: DialogConfig<HecConnectDialogParams, DialogRef<HecConnectDialogResult>>,
) {
return dialogService.open<HecConnectDialogResult>(ConnectHecDialogComponent, config);
}

View File

@@ -1 +0,0 @@
export * from "./connect-dialog/connect-dialog-hec.component";

View File

@@ -1,22 +0,0 @@
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none lg:tw-grid-cols-4 lg:tw-gap-10 lg:tw-w-auto"
>
<li
*ngFor="let integration of integrations"
[title]="tooltipI18nKey | i18n: integration.name"
[attr.aria-label]="ariaI18nKey | i18n: integration.name"
>
<app-integration-card
[name]="integration.name"
[linkURL]="integration.linkURL"
[image]="integration.image"
[imageDarkMode]="integration.imageDarkMode"
[externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration"
[description]="integration.description | i18n"
[isConnected]="integration.isConnected"
[canSetupConnection]="integration.canSetupConnection"
[integrationSettings]="integration"
></app-integration-card>
</li>
</ul>

View File

@@ -1,128 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
import { 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 { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
// eslint-disable-next-line import/order
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
import { IntegrationGridComponent } from "./integration-grid.component";
describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const integrations: Integration[] = [
{
name: "Integration 1",
image: "test-image1.png",
linkURL: "https://example.com/1",
type: IntegrationType.Integration,
},
{
name: "SDK 2",
image: "test-image2.png",
linkURL: "https://example.com/2",
type: IntegrationType.SDK,
},
];
beforeEach(() => {
mockActivatedRoute.snapshot = {
paramMap: {
get: jest.fn().mockReturnValue("test-organization-id"),
},
} as any;
TestBed.configureTestingModule({
imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule],
providers: [
{
provide: ThemeStateService,
useValue: mock<ThemeStateService>(),
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Light),
},
{
provide: I18nPipe,
useValue: mock<I18nPipe>(),
},
{
provide: I18nService,
useValue: mock<I18nService>({ t: (key, p1) => key + " " + p1 }),
},
{
provide: ActivatedRoute,
useValue: mockActivatedRoute,
},
{
provide: OrganizationIntegrationApiService,
useValue: mockOrgIntegrationApiService,
},
{
provide: ToastService,
useValue: mock<ToastService>(),
},
],
});
fixture = TestBed.createComponent(IntegrationGridComponent);
component = fixture.componentInstance;
component.integrations = integrations;
component.ariaI18nKey = "integrationCardAriaLabel";
component.tooltipI18nKey = "integrationCardTooltip";
fixture.detectChanges();
});
it("lists all integrations", () => {
expect(component.integrations).toEqual(integrations);
const cards = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(cards.length).toBe(integrations.length);
});
it("assigns the correct attributes to IntegrationCardComponent", () => {
expect(component.integrations).toEqual(integrations);
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent))[1];
expect(card.componentInstance.name).toBe("SDK 2");
expect(card.componentInstance.image).toBe("test-image2.png");
expect(card.componentInstance.linkURL).toBe("https://example.com/2");
});
it("assigns `externalURL` for SDKs", () => {
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(card[0].componentInstance.externalURL).toBe(false);
expect(card[1].componentInstance.externalURL).toBe(true);
});
it("has a tool tip and aria label attributes", () => {
const card: HTMLElement = fixture.debugElement.queryAll(By.css("li"))[0].nativeElement;
expect(card.title).toBe("integrationCardTooltip" + " " + integrations[0].name);
expect(card.getAttribute("aria-label")).toBe(
"integrationCardAriaLabel" + " " + integrations[0].name,
);
});
});

View File

@@ -1,23 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { SharedModule } from "../../../../../../shared/shared.module";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
@Component({
selector: "app-integration-grid",
templateUrl: "./integration-grid.component.html",
imports: [IntegrationCardComponent, SharedModule],
})
export class IntegrationGridComponent {
@Input() integrations: Integration[];
@Input() ariaI18nKey: string = "integrationCardAriaLabel";
@Input() tooltipI18nKey: string = "integrationCardTooltip";
protected IntegrationType = IntegrationType;
}

View File

@@ -1,14 +0,0 @@
import { Pipe, PipeTransform } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { Integration } from "../../../shared/components/integrations/models";
@Pipe({
name: "filterIntegrations",
})
export class FilterIntegrationsPipe implements PipeTransform {
transform(integrations: Integration[], type: IntegrationType): Integration[] {
return integrations.filter((integration) => integration.type === type);
}
}

View File

@@ -1,24 +0,0 @@
import { IntegrationType } from "@bitwarden/common/enums";
/** Integration or SDK */
export type Integration = {
name: string;
image: string;
/**
* Optional image shown in dark mode.
*/
imageDarkMode?: string;
linkURL: string;
type: IntegrationType;
/**
* Shows the "New" badge until the defined date.
* When omitted, the badge is never shown.
*
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
description?: string;
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
};

View File

@@ -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({

View File

@@ -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": {