mirror of
https://github.com/bitwarden/browser
synced 2025-12-22 11:13:46 +00:00
[PM-23824] Implement HEC integration (#16274)
This commit is contained in:
@@ -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,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<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">{{ "connected" | 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()">
|
||||
@if (isUpdateAvailable) {
|
||||
<span>{{ "updateIntegrationButtonDesc" | i18n: name }}</span>
|
||||
} @else {
|
||||
<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"
|
||||
title="{{ linkURL }}"
|
||||
>
|
||||
</a>
|
||||
}
|
||||
@if (showNewBadge()) {
|
||||
<span bitBadge class="tw-mt-3" variant="secondary">
|
||||
{{ "new" | i18n }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,353 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { 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 { 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<IntegrationCardComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const activatedRoute = mock<ActivatedRoute>();
|
||||
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
|
||||
const dialogService = mock<DialogService>();
|
||||
const toastService = mock<ToastService>();
|
||||
|
||||
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: HecOrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
}).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("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("returns false when canSetupConnection is false", () => {
|
||||
component.canSetupConnection = false;
|
||||
expect(component.showConnectedBadge()).toBe(false);
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
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";
|
||||
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 { openHecConnectDialog } from "../integration-dialog/index";
|
||||
|
||||
@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() canSetupConnection?: boolean;
|
||||
|
||||
organizationId: OrganizationId;
|
||||
|
||||
constructor(
|
||||
private themeStateService: ThemeStateService,
|
||||
@Inject(SYSTEM_THEME_OBSERVABLE)
|
||||
private systemTheme$: Observable<ThemeType>,
|
||||
private dialogService: DialogService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
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$])
|
||||
.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();
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return !!this.integrationSettings.organizationIntegration?.configuration;
|
||||
}
|
||||
|
||||
showConnectedBadge(): boolean {
|
||||
return this.canSetupConnection ?? false;
|
||||
}
|
||||
|
||||
get isUpdateAvailable(): boolean {
|
||||
return !!this.integrationSettings.organizationIntegration;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
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: "",
|
||||
message: this.i18nService.t("failedToSaveIntegration"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<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">
|
||||
@if (isUpdateAvailable) {
|
||||
{{ "update" | i18n }}
|
||||
} @else {
|
||||
{{ "save" | i18n }}
|
||||
}
|
||||
</button>
|
||||
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,175 @@
|
||||
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 { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
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 {
|
||||
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, // Provide appropriate mock template if needed
|
||||
};
|
||||
|
||||
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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration";
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
export type HecConnectDialogParams = {
|
||||
settings: Integration;
|
||||
};
|
||||
|
||||
export interface HecConnectDialogResult {
|
||||
integrationSettings: Integration;
|
||||
url: string;
|
||||
bearerToken: string;
|
||||
index: string;
|
||||
service: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./connect-dialog-hec.component.html",
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class ConnectHecDialogComponent implements OnInit {
|
||||
loading = false;
|
||||
hecConfig: HecConfiguration | null = null;
|
||||
hecTemplate: HecTemplate | null = null;
|
||||
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 {
|
||||
this.hecConfig =
|
||||
this.connectInfo.settings.organizationIntegration?.getConfiguration<HecConfiguration>() ??
|
||||
null;
|
||||
this.hecTemplate =
|
||||
this.connectInfo.settings.organizationIntegration?.integrationConfiguration?.[0]?.getTemplate<HecTemplate>() ??
|
||||
null;
|
||||
|
||||
this.formGroup.patchValue({
|
||||
url: this.hecConfig?.uri || "",
|
||||
bearerToken: this.hecConfig?.token || "",
|
||||
index: this.hecTemplate?.index || "",
|
||||
service: this.connectInfo.settings.name,
|
||||
});
|
||||
}
|
||||
|
||||
get isUpdateAvailable(): boolean {
|
||||
return !!this.hecConfig;
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
const formJson = this.formGroup.getRawValue();
|
||||
|
||||
const result: HecConnectDialogResult = {
|
||||
integrationSettings: this.connectInfo.settings,
|
||||
url: formJson.url || "",
|
||||
bearerToken: formJson.bearerToken || "",
|
||||
index: formJson.index || "",
|
||||
service: formJson.service || "",
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./connect-dialog/connect-dialog-hec.component";
|
||||
@@ -0,0 +1,21 @@
|
||||
<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"
|
||||
[canSetupConnection]="integration.canSetupConnection"
|
||||
[integrationSettings]="integration"
|
||||
></app-integration-card>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,120 @@
|
||||
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";
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
|
||||
|
||||
import { IntegrationGridComponent } from "./integration-grid.component";
|
||||
|
||||
describe("IntegrationGridComponent", () => {
|
||||
let component: IntegrationGridComponent;
|
||||
let fixture: ComponentFixture<IntegrationGridComponent>;
|
||||
const mockActivatedRoute = mock<ActivatedRoute>();
|
||||
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
|
||||
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: HecOrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<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>
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "./integrations.pipe";
|
||||
|
||||
@Component({
|
||||
selector: "ac-integrations",
|
||||
templateUrl: "./integrations.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe],
|
||||
})
|
||||
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
tabIndex: number = 0;
|
||||
organization$: Observable<Organization> = new 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,
|
||||
},
|
||||
];
|
||||
|
||||
async ngOnInit() {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
if (!userId) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
this.organization$ = this.route.params.pipe(
|
||||
switchMap((params) =>
|
||||
this.organizationService.organizations$(userId).pipe(
|
||||
getOrganizationById(params.organizationId),
|
||||
// Filter out undefined values
|
||||
takeWhile((org: Organization | undefined) => !!org),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sets the organization ID which also loads the integrations$
|
||||
this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
|
||||
this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
|
||||
) {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((isEnabled) => {
|
||||
this.isEventBasedIntegrationsEnabled = isEnabled;
|
||||
});
|
||||
|
||||
// Add the new event based items to the list
|
||||
if (this.isEventBasedIntegrationsEnabled) {
|
||||
const crowdstrikeIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
linkURL: "",
|
||||
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
description: "crowdstrikeEventIntegrationDesc",
|
||||
isConnected: false,
|
||||
canSetupConnection: true,
|
||||
};
|
||||
|
||||
this.integrationsList.push(crowdstrikeIntegration);
|
||||
}
|
||||
|
||||
// For all existing event based configurations loop through and assign the
|
||||
// organizationIntegration for the correct services.
|
||||
this.hecOrganizationIntegrationService.integrations$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
integrations.map((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
|
||||
if (item) {
|
||||
item.organizationIntegration = integration;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// use in the view
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
|
||||
@Pipe({
|
||||
name: "filterIntegrations",
|
||||
})
|
||||
export class FilterIntegrationsPipe implements PipeTransform {
|
||||
transform(integrations: Integration[], type: IntegrationType): Integration[] {
|
||||
return integrations.filter((integration) => integration.type === type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
|
||||
|
||||
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)],
|
||||
component: AdminConsoleIntegrationsComponent,
|
||||
data: {
|
||||
titleId: "integrations",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class OrganizationIntegrationsRoutingModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
|
||||
import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module";
|
||||
|
||||
@NgModule({
|
||||
imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: HecOrganizationIntegrationService,
|
||||
useClass: HecOrganizationIntegrationService,
|
||||
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationIntegrationApiService,
|
||||
useClass: OrganizationIntegrationApiService,
|
||||
deps: [ApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationIntegrationConfigurationApiService,
|
||||
useClass: OrganizationIntegrationConfigurationApiService,
|
||||
deps: [ApiService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class OrganizationIntegrationsModule {}
|
||||
@@ -9,14 +9,14 @@ import {} from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
|
||||
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";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { IntegrationCardComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component";
|
||||
import { IntegrationGridComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component";
|
||||
|
||||
import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
|
||||
import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
|
||||
|
||||
import { IntegrationsComponent } from "./integrations.component";
|
||||
|
||||
@@ -36,8 +36,8 @@ class MockNewMenuComponent {}
|
||||
|
||||
describe("IntegrationsComponent", () => {
|
||||
let fixture: ComponentFixture<IntegrationsComponent>;
|
||||
const hecOrgIntegrationSvc = mock<HecOrganizationIntegrationService>();
|
||||
|
||||
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const activatedRouteMock = {
|
||||
snapshot: { paramMap: { get: jest.fn() } },
|
||||
};
|
||||
@@ -52,10 +52,9 @@ describe("IntegrationsComponent", () => {
|
||||
{ provide: ThemeStateService, useValue: mock<ThemeStateService>() },
|
||||
{ provide: SYSTEM_THEME_OBSERVABLE, useValue: of(ThemeType.Light) },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteMock },
|
||||
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(IntegrationsComponent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { Integration } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/models";
|
||||
|
||||
@Component({
|
||||
selector: "sm-integrations",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { IntegrationCardComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component";
|
||||
import { IntegrationGridComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component";
|
||||
|
||||
import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
|
||||
import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
|
||||
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
|
||||
|
||||
import { IntegrationsRoutingModule } from "./integrations-routing.module";
|
||||
|
||||
Reference in New Issue
Block a user