mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 19:23:19 +00:00
PM-23824 saving and retrieve data from two tables
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs";
|
||||
import { Observable, Subject, switchMap, takeUntil, combineLatest } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
|
||||
import { HecConfiguration } from "@bitwarden/bit-common/dirt/integrations";
|
||||
import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
@@ -22,6 +22,7 @@ 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";
|
||||
import { OrganizationIntegrationService } from "../shared/components/integrations/services/organization-integration.service";
|
||||
|
||||
@Component({
|
||||
selector: "ac-integrations",
|
||||
@@ -218,9 +219,11 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
async ngOnInit(): Promise<void> {
|
||||
const orgId = this.route.snapshot.params.organizationId;
|
||||
|
||||
await this.organizationIntegrationService.getIntegrationsAndConfigurations(orgId);
|
||||
|
||||
this.organization$ = this.route.params.pipe(
|
||||
switchMap((params) =>
|
||||
this.accountService.activeAccount$.pipe(
|
||||
@@ -233,23 +236,41 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler)
|
||||
combineLatest([
|
||||
this.organizationIntegrationService.integrations$,
|
||||
this.organizationIntegrationService.integrationConfigurations$,
|
||||
])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
.subscribe(([integrations, integrationConfigurations]) => {
|
||||
const existingIntegrations = [...this.integrationsList];
|
||||
|
||||
// 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);
|
||||
const configJson = this.organizationIntegrationService.convertToJson<HecConfiguration>(
|
||||
integration.configuration,
|
||||
);
|
||||
const serviceName = configJson?.service ?? "";
|
||||
const existingIntegration = existingIntegrations.find((i) => i.name === serviceName);
|
||||
|
||||
if (existingIntegration) {
|
||||
// if a configuration exists, then it is connected
|
||||
// update integrations
|
||||
existingIntegration.isConnected = !!integration.configuration;
|
||||
existingIntegration.configuration = integration.configuration || "";
|
||||
|
||||
const template = this.organizationIntegrationService.getIntegrationConfiguration(
|
||||
integration.id,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
|
||||
existingIntegration.template = JSON.stringify(template || {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// update the integrations list
|
||||
this.integrationsList = existingIntegrations;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +279,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private orgIntegrationApiService: OrganizationIntegrationApiService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
) {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
|
||||
@@ -284,6 +305,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// use in the view
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./integrations.pipe";
|
||||
export * from "./integration-card/integration-card.component";
|
||||
export * from "./integration-grid/integration-grid.component";
|
||||
export * from "./models";
|
||||
export * from "./services/organization-integration.service";
|
||||
|
||||
@@ -4,8 +4,6 @@ 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";
|
||||
@@ -14,6 +12,8 @@ import { ToastService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/components/src/shared";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { OrganizationIntegrationService } from "../services/organization-integration.service";
|
||||
|
||||
import { IntegrationCardComponent } from "./integration-card.component";
|
||||
|
||||
describe("IntegrationCardComponent", () => {
|
||||
@@ -21,7 +21,7 @@ describe("IntegrationCardComponent", () => {
|
||||
let fixture: ComponentFixture<IntegrationCardComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const activatedRoute = mock<ActivatedRoute>();
|
||||
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const mockIntegrationService = mock<OrganizationIntegrationService>();
|
||||
|
||||
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
|
||||
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
|
||||
@@ -43,7 +43,7 @@ describe("IntegrationCardComponent", () => {
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
|
||||
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -15,11 +15,9 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
|
||||
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";
|
||||
HecConfiguration,
|
||||
HecConfigurationTemplate,
|
||||
} from "@bitwarden/bit-common/dirt/integrations";
|
||||
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";
|
||||
@@ -29,6 +27,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { SharedModule } from "../../../../../../shared/shared.module";
|
||||
import { openHecConnectDialog } from "../integration-dialog/index";
|
||||
import { Integration } from "../models";
|
||||
import { OrganizationIntegrationService } from "../services/organization-integration.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-integration-card",
|
||||
@@ -59,18 +58,24 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
@Input() isConnected?: boolean;
|
||||
@Input() canSetupConnection?: boolean;
|
||||
|
||||
organizationId: OrganizationId;
|
||||
|
||||
constructor(
|
||||
private themeStateService: ThemeStateService,
|
||||
@Inject(SYSTEM_THEME_OBSERVABLE)
|
||||
private systemTheme$: Observable<ThemeType>,
|
||||
private dialogService: DialogService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private apiService: OrganizationIntegrationApiService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
|
||||
"organizationId",
|
||||
) as OrganizationId;
|
||||
|
||||
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
|
||||
.pipe(takeUntil(this.destroyed$))
|
||||
.subscribe(([theme, systemTheme]) => {
|
||||
@@ -125,6 +130,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
const dialog = openHecConnectDialog(this.dialogService, {
|
||||
data: {
|
||||
settings: this.integrationSettings,
|
||||
configuration: this.organizationIntegrationService.convertToJson<HecConfiguration>(
|
||||
this.integrationSettings.configuration,
|
||||
),
|
||||
template: this.organizationIntegrationService.convertToJson<HecConfigurationTemplate>(
|
||||
this.integrationSettings.template,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,11 +146,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// create integration and configuration objects
|
||||
const integration = new HecConfiguration(result.url, result.bearerToken, result.service);
|
||||
const configurationTemplate = new HecConfigurationTemplate(result.index, result.service);
|
||||
|
||||
// save the integration
|
||||
try {
|
||||
const dbResponse = await this.saveHecIntegration(result.configuration);
|
||||
this.isConnected = !!dbResponse.id;
|
||||
} catch {
|
||||
const dbResponse = await this.organizationIntegrationService.saveHec(
|
||||
this.organizationId,
|
||||
this.integrationSettings.name,
|
||||
integration,
|
||||
configurationTemplate,
|
||||
);
|
||||
|
||||
if (!!dbResponse.integration && !!dbResponse.configuration) {
|
||||
this.isConnected = true;
|
||||
this.integrationSettings.configuration = dbResponse.integration.configuration;
|
||||
this.integrationSettings.template = dbResponse.configuration.template;
|
||||
}
|
||||
} catch (err) {
|
||||
this.isConnected = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save integration", err);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
@@ -147,34 +176,5 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// update the configuration
|
||||
this.integrationSettings.configuration = result.configuration;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +150,10 @@ describe("ConnectDialogHecComponent", () => {
|
||||
|
||||
expect(dialogRefMock.close).toHaveBeenCalledWith({
|
||||
integrationSettings: integrationMock,
|
||||
configuration: JSON.stringify({
|
||||
url: "https://test.com",
|
||||
bearerToken: "token",
|
||||
index: "1",
|
||||
service: "Test Service",
|
||||
}),
|
||||
url: "https://test.com",
|
||||
bearerToken: "token",
|
||||
index: "1",
|
||||
service: "Test Service",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
HecConfiguration,
|
||||
HecConfigurationTemplate,
|
||||
} from "@bitwarden/bit-common/dirt/integrations";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@@ -8,11 +13,16 @@ import { Integration } from "../../models";
|
||||
|
||||
export type HecConnectDialogParams = {
|
||||
settings: Integration;
|
||||
configuration: HecConfiguration | null;
|
||||
template: HecConfigurationTemplate | null;
|
||||
};
|
||||
|
||||
export interface HecConnectDialogResult {
|
||||
integrationSettings: Integration;
|
||||
configuration: string;
|
||||
url: string;
|
||||
bearerToken: string;
|
||||
index: string;
|
||||
service: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -37,16 +47,12 @@ export class ConnectHecDialogComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
this.formGroup.patchValue({
|
||||
url: this.connectInfo.configuration?.uri || "",
|
||||
bearerToken: this.connectInfo.configuration?.token || "",
|
||||
index: this.connectInfo.template?.index || "",
|
||||
service: this.connectInfo.settings.name,
|
||||
});
|
||||
}
|
||||
|
||||
getSettingsAsJson(configuration: string) {
|
||||
@@ -62,7 +68,10 @@ export class ConnectHecDialogComponent implements OnInit {
|
||||
|
||||
const result: HecConnectDialogResult = {
|
||||
integrationSettings: this.connectInfo.settings,
|
||||
configuration: JSON.stringify(formJson),
|
||||
url: formJson.url,
|
||||
bearerToken: formJson.bearerToken,
|
||||
index: formJson.index,
|
||||
service: formJson.service,
|
||||
success: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,6 @@ 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";
|
||||
@@ -21,6 +19,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
|
||||
import { Integration } from "../models";
|
||||
import { OrganizationIntegrationService } from "../services/organization-integration.service";
|
||||
|
||||
import { IntegrationGridComponent } from "./integration-grid.component";
|
||||
|
||||
@@ -28,7 +27,7 @@ describe("IntegrationGridComponent", () => {
|
||||
let component: IntegrationGridComponent;
|
||||
let fixture: ComponentFixture<IntegrationGridComponent>;
|
||||
const mockActivatedRoute = mock<ActivatedRoute>();
|
||||
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const mockIntegrationService = mock<OrganizationIntegrationService>();
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
name: "Integration 1",
|
||||
@@ -74,10 +73,7 @@ describe("IntegrationGridComponent", () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: mockActivatedRoute,
|
||||
},
|
||||
{
|
||||
provide: OrganizationIntegrationApiService,
|
||||
useValue: mockOrgIntegrationApiService,
|
||||
},
|
||||
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: mock<ToastService>(),
|
||||
|
||||
@@ -21,4 +21,5 @@ export type Integration = {
|
||||
isConnected?: boolean;
|
||||
canSetupConnection?: boolean;
|
||||
configuration?: string;
|
||||
template?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationIntegrationApiService,
|
||||
OrganizationIntegrationConfigurationApiService,
|
||||
} from "@bitwarden/bit-common/dirt/integrations";
|
||||
|
||||
import { OrganizationIntegrationService } from "./organization-integration.service";
|
||||
|
||||
describe("OrganizationIntegrationService", () => {
|
||||
let service: OrganizationIntegrationService;
|
||||
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const mockOrgIntegrationConfigurationApiService =
|
||||
mock<OrganizationIntegrationConfigurationApiService>();
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
|
||||
{
|
||||
provide: OrganizationIntegrationConfigurationApiService,
|
||||
useValue: mockOrgIntegrationConfigurationApiService,
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(OrganizationIntegrationService);
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("getIntegrationsAndConfigurations", () => {
|
||||
const orgId = "org-123" as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset();
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset();
|
||||
service["integrations"].next([]);
|
||||
service["integrationConfigurations"].next([]);
|
||||
});
|
||||
|
||||
it("should fetch integrations and their configurations and update observables", async () => {
|
||||
const integration1 = { id: "int-1", type: "type1" } as any;
|
||||
const integration2 = { id: "int-2", type: "type2" } as any;
|
||||
const config1 = [{ id: "conf-1", template: "{}" }] as any;
|
||||
const config2 = [{ id: "conf-2", template: "{}" }] as any;
|
||||
|
||||
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([
|
||||
integration1,
|
||||
integration2,
|
||||
]);
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockImplementation(
|
||||
(_, integrationId) => {
|
||||
if (integrationId === "int-1") {
|
||||
return Promise.resolve(config1);
|
||||
}
|
||||
if (integrationId === "int-2") {
|
||||
return Promise.resolve(config2);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.getIntegrationsAndConfigurations(orgId);
|
||||
|
||||
expect(mockOrgIntegrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId);
|
||||
expect(
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations,
|
||||
).toHaveBeenCalledWith(orgId, "int-1");
|
||||
expect(
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations,
|
||||
).toHaveBeenCalledWith(orgId, "int-2");
|
||||
|
||||
expect(result.integrations).toEqual([integration1, integration2]);
|
||||
expect(result.configurations.length).toBe(2);
|
||||
expect(result.configurations[0].integrationId).toBe("int-1");
|
||||
expect(result.configurations[1].integrationId).toBe("int-2");
|
||||
|
||||
// Check observables updated
|
||||
service.integrations$.subscribe((integrations) => {
|
||||
expect(integrations).toEqual([integration1, integration2]);
|
||||
});
|
||||
service.integrationConfigurations$.subscribe((configs) => {
|
||||
expect(configs.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle no integrations", async () => {
|
||||
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
|
||||
const result = await service.getIntegrationsAndConfigurations(orgId);
|
||||
|
||||
expect(result.integrations).toEqual([]);
|
||||
expect(result.configurations).toEqual([]);
|
||||
service.integrations$.subscribe((integrations) => {
|
||||
expect(integrations).toEqual([]);
|
||||
});
|
||||
service.integrationConfigurations$.subscribe((configs) => {
|
||||
expect(configs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle integrations with no configurations", async () => {
|
||||
const integration = { id: "int-1", type: "type1" } as any;
|
||||
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([integration]);
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
const result = await service.getIntegrationsAndConfigurations(orgId);
|
||||
|
||||
expect(result.integrations).toEqual([integration]);
|
||||
expect(result.configurations.length).toBe(1);
|
||||
expect(result.configurations[0].integrationId).toBe("int-1");
|
||||
expect(result.configurations[0].configurationResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveHec", () => {
|
||||
const organizationId = "org-123" as any;
|
||||
const serviceName = "splunk";
|
||||
const hecConfiguration = { toString: () => '{"token":"abc"}' } as any;
|
||||
const hecConfigurationTemplate = { toString: () => '{"service":"splunk"}' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset();
|
||||
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset();
|
||||
service["integrations"].next([]);
|
||||
service["integrationConfigurations"].next([]);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should save HEC integration and configuration successfully", async () => {
|
||||
const integrationResponse = { id: "int-1", type: "Hec" } as any;
|
||||
const configurationResponse = { id: "conf-1", template: '{"service":"splunk"}' } as any;
|
||||
|
||||
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
|
||||
jest
|
||||
.spyOn(service, "saveHecIntegrationConfiguration")
|
||||
.mockResolvedValue(configurationResponse);
|
||||
|
||||
const result = await service.saveHec(
|
||||
organizationId,
|
||||
serviceName,
|
||||
hecConfiguration,
|
||||
hecConfigurationTemplate,
|
||||
);
|
||||
|
||||
expect(service.saveHecIntegration).toHaveBeenCalledWith(organizationId, hecConfiguration);
|
||||
expect(service.saveHecIntegrationConfiguration).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
integrationResponse.id,
|
||||
serviceName,
|
||||
hecConfigurationTemplate,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
integration: integrationResponse,
|
||||
configuration: configurationResponse,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if integrationResponse.id is missing", async () => {
|
||||
const integrationResponse = { type: "Hec" } as any;
|
||||
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
|
||||
|
||||
await expect(
|
||||
service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate),
|
||||
).rejects.toThrow("Failed to save HEC integration");
|
||||
});
|
||||
|
||||
it("should throw error if configurationResponse.id is missing", async () => {
|
||||
const integrationResponse = { id: "int-1", type: "Hec" } as any;
|
||||
const configurationResponse = { template: '{"service":"splunk"}' } as any;
|
||||
|
||||
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
|
||||
jest
|
||||
.spyOn(service, "saveHecIntegrationConfiguration")
|
||||
.mockResolvedValue(configurationResponse);
|
||||
|
||||
await expect(
|
||||
service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate),
|
||||
).rejects.toThrow("Failed to save HEC integration configuration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationConfiguration", () => {
|
||||
const integrationId = "int-1" as any;
|
||||
const serviceName = "splunk";
|
||||
const otherServiceName = "datadog";
|
||||
|
||||
it("should return null if integrationConfigurations is empty", () => {
|
||||
const result = service.getIntegrationConfiguration(integrationId, serviceName, []);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if no integrationConfigs found for integrationId", () => {
|
||||
const integrationConfigurations = [
|
||||
{
|
||||
integrationId: "int-2",
|
||||
configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if no configurationResponses match the service", () => {
|
||||
const sampleIntegrationConfigurations = [
|
||||
{
|
||||
integrationId,
|
||||
configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
otherServiceName,
|
||||
sampleIntegrationConfigurations,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the configuration template if service matches", () => {
|
||||
const template = { service: "splunk", token: "abc" };
|
||||
const integrationConfigurations = [
|
||||
{
|
||||
integrationId,
|
||||
configurationResponses: [
|
||||
{ id: "conf-1", template: JSON.stringify(template) },
|
||||
{ id: "conf-2", template: '{"service":"datadog"}' },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
expect(result).toEqual(template);
|
||||
});
|
||||
|
||||
it("should return the first matching configuration if multiple match", () => {
|
||||
const template1 = { service: "splunk", token: "abc" };
|
||||
const template2 = { service: "splunk", token: "def" };
|
||||
const integrationConfigurations = [
|
||||
{
|
||||
integrationId,
|
||||
configurationResponses: [
|
||||
{ id: "conf-1", template: JSON.stringify(template1) },
|
||||
{ id: "conf-2", template: JSON.stringify(template2) },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
expect(result).toEqual(template1);
|
||||
});
|
||||
|
||||
it("should skip invalid JSON templates", () => {
|
||||
const template = { service: "splunk", token: "abc" };
|
||||
const integrationConfigurations = [
|
||||
{
|
||||
integrationId,
|
||||
configurationResponses: [
|
||||
{ id: "conf-1", template: "invalid-json" },
|
||||
{ id: "conf-2", template: JSON.stringify(template) },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
expect(result).toEqual(template);
|
||||
});
|
||||
|
||||
it("should return null if all templates are invalid JSON", () => {
|
||||
const integrationConfigurations = [
|
||||
{
|
||||
integrationId,
|
||||
configurationResponses: [
|
||||
{ id: "conf-1", template: "invalid-json" },
|
||||
{ id: "conf-2", template: "" },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const result = service.getIntegrationConfiguration(
|
||||
integrationId,
|
||||
serviceName,
|
||||
integrationConfigurations,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToJson", () => {
|
||||
it("should parse valid JSON string and return object", () => {
|
||||
const jsonString = '{"foo":"bar","num":42}';
|
||||
const result = service.convertToJson<{ foo: string; num: number }>(jsonString);
|
||||
expect(result).toEqual({ foo: "bar", num: 42 });
|
||||
});
|
||||
|
||||
it("should return null for invalid JSON string", () => {
|
||||
const invalidJson = '{"foo":bar}';
|
||||
const result = service.convertToJson<any>(invalidJson);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
const result = service.convertToJson<any>("");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should parse JSON arrays", () => {
|
||||
const jsonString = "[1,2,3]";
|
||||
const result = service.convertToJson<number[]>(jsonString);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("should parse JSON boolean", () => {
|
||||
const jsonString = "true";
|
||||
const result = service.convertToJson<boolean>(jsonString);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should parse JSON number", () => {
|
||||
const jsonString = "123";
|
||||
const result = service.convertToJson<number>(jsonString);
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
it("should parse JSON null", () => {
|
||||
const jsonString = "null";
|
||||
const result = service.convertToJson<any>(jsonString);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
OrganizationIntegrationApiService,
|
||||
OrganizationIntegrationConfigurationApiService,
|
||||
OrganizationIntegrationConfigurationRequest,
|
||||
OrganizationIntegrationConfigurationResponse,
|
||||
HecConfiguration,
|
||||
OrganizationIntegrationRequest,
|
||||
OrganizationIntegrationResponse,
|
||||
OrganizationIntegrationType,
|
||||
HecConfigurationTemplate,
|
||||
OrganizationIntegrationConfigurationResponseWithIntegrationId,
|
||||
} from "@bitwarden/bit-common/dirt/integrations";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class OrganizationIntegrationService {
|
||||
private integrations = new BehaviorSubject<OrganizationIntegrationResponse[]>([]);
|
||||
integrations$ = this.integrations.asObservable();
|
||||
|
||||
private integrationConfigurations = new BehaviorSubject<
|
||||
OrganizationIntegrationConfigurationResponseWithIntegrationId[]
|
||||
>([]);
|
||||
integrationConfigurations$ = this.integrationConfigurations.asObservable();
|
||||
|
||||
constructor(
|
||||
private integrationApiService: OrganizationIntegrationApiService,
|
||||
private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
|
||||
) {}
|
||||
|
||||
/*
|
||||
* Fetches the integrations and their configurations for a specific organization.
|
||||
* @param orgId The ID of the organization.
|
||||
* Invoke this method to retrieve the integrations into observable.
|
||||
*/
|
||||
async getIntegrationsAndConfigurations(orgId: OrganizationId) {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const integrations = await this.integrationApiService.getOrganizationIntegrations(orgId);
|
||||
const integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[] =
|
||||
[];
|
||||
|
||||
integrations.forEach((integration) => {
|
||||
const promise = this.integrationConfigurationApiService
|
||||
.getOrganizationIntegrationConfigurations(orgId, integration.id)
|
||||
.then((configs) => {
|
||||
const mappedConfigurations =
|
||||
new OrganizationIntegrationConfigurationResponseWithIntegrationId(
|
||||
integration.id,
|
||||
configs,
|
||||
);
|
||||
integrationConfigurations.push(mappedConfigurations);
|
||||
});
|
||||
promises.push(promise);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.integrations.next(integrations);
|
||||
this.integrationConfigurations.next(integrationConfigurations);
|
||||
|
||||
return {
|
||||
integrations: integrations,
|
||||
configurations: integrationConfigurations,
|
||||
};
|
||||
}
|
||||
|
||||
async saveHec(
|
||||
organizationId: OrganizationId,
|
||||
service: string,
|
||||
hecConfiguration: HecConfiguration,
|
||||
hecConfigurationTemplate: HecConfigurationTemplate,
|
||||
) {
|
||||
const integrationResponse = await this.saveHecIntegration(organizationId, hecConfiguration);
|
||||
|
||||
if (!integrationResponse.id) {
|
||||
throw new Error("Failed to save HEC integration");
|
||||
}
|
||||
|
||||
// Save the configuration for the HEC integration
|
||||
const configurationResponse = await this.saveHecIntegrationConfiguration(
|
||||
organizationId,
|
||||
integrationResponse.id,
|
||||
service,
|
||||
hecConfigurationTemplate,
|
||||
);
|
||||
|
||||
if (!configurationResponse.id) {
|
||||
throw new Error("Failed to save HEC integration configuration");
|
||||
}
|
||||
|
||||
return {
|
||||
integration: integrationResponse,
|
||||
configuration: configurationResponse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the HEC integration configuration for a specific organization.
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param configuration The HEC integration configuration.
|
||||
* @param index The index of the integration configuration to update, if it exists.
|
||||
* @returns The saved or updated integration response.
|
||||
*
|
||||
* This method checks if an existing HEC integration exists for the organization.
|
||||
* If it does, it updates the existing integration; otherwise, it creates a new one.
|
||||
* The method returns the saved or updated integration response.
|
||||
*/
|
||||
async saveHecIntegration(
|
||||
organizationId: OrganizationId,
|
||||
hecConfiguration: HecConfiguration,
|
||||
): Promise<OrganizationIntegrationResponse> {
|
||||
const request = new OrganizationIntegrationRequest(
|
||||
OrganizationIntegrationType.Hec,
|
||||
hecConfiguration.toString(),
|
||||
);
|
||||
|
||||
// find the existing integration
|
||||
const existingIntegration = this.integrations.value.find(
|
||||
(i) => i.type === OrganizationIntegrationType.Hec,
|
||||
);
|
||||
|
||||
if (existingIntegration) {
|
||||
// existing integration record found, invoke update API endpoint
|
||||
const updatedIntegration = await this.integrationApiService.updateOrganizationIntegration(
|
||||
organizationId,
|
||||
existingIntegration.id,
|
||||
request,
|
||||
);
|
||||
|
||||
// update our observable with the updated integration
|
||||
const updatedIntegrations = this.integrations.value.map((integration) => {
|
||||
if (integration.id === existingIntegration.id) {
|
||||
return updatedIntegration;
|
||||
}
|
||||
return integration;
|
||||
});
|
||||
|
||||
this.integrations.next(updatedIntegrations);
|
||||
|
||||
return updatedIntegration;
|
||||
} else {
|
||||
// no existing integration found, invoke create API endpoint
|
||||
const newIntegration = await this.integrationApiService.createOrganizationIntegration(
|
||||
organizationId,
|
||||
request,
|
||||
);
|
||||
|
||||
// add this to our integrations observable
|
||||
this.integrations.next([...this.integrations.value, newIntegration]);
|
||||
return newIntegration;
|
||||
}
|
||||
}
|
||||
|
||||
/** * Saves the HEC integration configuration for a specific organization and integration.
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param integrationId The ID of the integration.
|
||||
* @param configurationTemplate The HEC integration configuration.
|
||||
* @returns The saved or updated integration configuration response.
|
||||
*
|
||||
* This method checks if an existing configuration exists for the given integration.
|
||||
* If it does, it updates the existing configuration; otherwise, it creates a new one.
|
||||
* The method returns the saved or updated configuration response.
|
||||
*/
|
||||
async saveHecIntegrationConfiguration(
|
||||
organizationId: OrganizationId,
|
||||
integrationId: OrganizationIntegrationId,
|
||||
service: string,
|
||||
configurationTemplate: HecConfigurationTemplate,
|
||||
): Promise<OrganizationIntegrationConfigurationResponse> {
|
||||
const request = new OrganizationIntegrationConfigurationRequest(
|
||||
EventType.Organization_Updated,
|
||||
null,
|
||||
null,
|
||||
configurationTemplate.toString(),
|
||||
);
|
||||
|
||||
// check if we have an existing configuration for this integration - in case of new records
|
||||
const integrationConfigurations = this.integrationConfigurations.value;
|
||||
|
||||
// find the existing configuration
|
||||
const existingConfigurations = integrationConfigurations
|
||||
.filter((config) => config.integrationId === integrationId)
|
||||
.flatMap((config) => config.configurationResponses || []);
|
||||
|
||||
const existingConfiguration =
|
||||
existingConfigurations.length > 0
|
||||
? existingConfigurations.find(
|
||||
(config) =>
|
||||
config.template &&
|
||||
this.convertToJson<HecConfigurationTemplate>(config.template)?.service === service,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (existingConfiguration) {
|
||||
// existing configuration found, invoke update API endpoint
|
||||
const updatedConfiguration =
|
||||
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
integrationId,
|
||||
existingConfiguration.id,
|
||||
request,
|
||||
);
|
||||
|
||||
// update our configurations for the integration
|
||||
integrationConfigurations.forEach((integrationConfig) => {
|
||||
if (integrationConfig.integrationId === integrationId) {
|
||||
integrationConfig.configurationResponses = integrationConfig.configurationResponses.map(
|
||||
(config) => {
|
||||
return config.id === existingConfiguration.id ? updatedConfiguration : config;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.integrationConfigurations.next(integrationConfigurations);
|
||||
|
||||
return updatedConfiguration;
|
||||
} else {
|
||||
// no existing configuration found, invoke create API endpoint
|
||||
const newConfiguration =
|
||||
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
integrationId,
|
||||
request,
|
||||
);
|
||||
|
||||
// add the new configuration to the integration configurations
|
||||
const integrationConfig = integrationConfigurations.find(
|
||||
(config) => config.integrationId === integrationId,
|
||||
);
|
||||
if (integrationConfig) {
|
||||
integrationConfig.configurationResponses.push(newConfiguration);
|
||||
}
|
||||
|
||||
this.integrationConfigurations.next(integrationConfigurations);
|
||||
return newConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
getIntegrationConfiguration(
|
||||
integrationId: OrganizationIntegrationId,
|
||||
service: string,
|
||||
integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[],
|
||||
): HecConfigurationTemplate | null {
|
||||
if (integrationConfigurations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const integrationConfigs = integrationConfigurations.find(
|
||||
(config) => config.integrationId === integrationId,
|
||||
);
|
||||
|
||||
if (!integrationConfigs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const config of integrationConfigs.configurationResponses) {
|
||||
const template = this.convertToJson<HecConfigurationTemplate>(config.template || "");
|
||||
if (template && template.service === service) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
convertToJson<T>(jsonString: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
LoginEmailService,
|
||||
} from "@bitwarden/auth/common";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
|
||||
import {
|
||||
OrganizationIntegrationApiService,
|
||||
OrganizationIntegrationConfigurationApiService,
|
||||
} 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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
@@ -402,6 +405,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: OrganizationIntegrationApiService,
|
||||
deps: [ApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationIntegrationConfigurationApiService,
|
||||
useClass: OrganizationIntegrationConfigurationApiService,
|
||||
deps: [ApiService],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { OrganizationIntegrationConfigurationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
OrganizationIntegrationConfigurationId,
|
||||
OrganizationIntegrationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
|
||||
id: OrganizationIntegrationConfigurationId;
|
||||
@@ -18,3 +21,16 @@ export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
|
||||
this.template = this.getResponseProperty("Template");
|
||||
}
|
||||
}
|
||||
|
||||
export class OrganizationIntegrationConfigurationResponseWithIntegrationId {
|
||||
integrationId: OrganizationIntegrationId;
|
||||
configurationResponses: OrganizationIntegrationConfigurationResponse[];
|
||||
|
||||
constructor(
|
||||
integrationId: OrganizationIntegrationId,
|
||||
configurationResponses: OrganizationIntegrationConfigurationResponse[],
|
||||
) {
|
||||
this.integrationId = integrationId;
|
||||
this.configurationResponses = configurationResponses;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,40 @@ export class OrganizationIntegrationRequest {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Represents the configuration for a HEC (HTTP Event Collector) integration.
|
||||
* Configuration model that is required by OrganizationIntegration.
|
||||
*/
|
||||
export class HecConfiguration {
|
||||
uri: string;
|
||||
scheme = "bearer";
|
||||
token: string;
|
||||
service: string;
|
||||
|
||||
constructor(uri: string, token: string, service: string) {
|
||||
this.uri = uri;
|
||||
this.token = token;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class HecConfigurationTemplate {
|
||||
event = "#EventMessage";
|
||||
source = "Bitwarden";
|
||||
index: string;
|
||||
service: string;
|
||||
|
||||
constructor(index: string, service: string) {
|
||||
this.index = index;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user