1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-24655] Delete an existing Integration (#16382)

This commit is contained in:
Vijay Oommen
2025-09-16 09:30:11 -05:00
committed by GitHub
parent befc756a02
commit 59396f0262
9 changed files with 252 additions and 50 deletions

View File

@@ -9670,6 +9670,9 @@
"failedToSaveIntegration": { "failedToSaveIntegration": {
"message": "Failed to save integration. Please try again later." "message": "Failed to save integration. Please try again later."
}, },
"failedToDeleteIntegration": {
"message": "Failed to delete integration. Please try again later."
},
"deviceIdMissing": { "deviceIdMissing": {
"message": "Device ID is missing" "message": "Device ID is missing"
}, },

View File

@@ -20,7 +20,6 @@ export type Integration = {
*/ */
newBadgeExpiration?: string; newBadgeExpiration?: string;
description?: string; description?: string;
isConnected?: boolean;
canSetupConnection?: boolean; canSetupConnection?: boolean;
configuration?: string; configuration?: string;
template?: string; template?: string;

View File

@@ -156,6 +156,34 @@ export class HecOrganizationIntegrationService {
} }
} }
async deleteHec(
organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
) {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// delete the integration
await this.integrationApiService.deleteOrganizationIntegration(
organizationId,
OrganizationIntegrationId,
);
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next(updatedIntegrations);
}
/** /**
* Gets a OrganizationIntegration for an OrganizationIntegrationId * Gets a OrganizationIntegration for an OrganizationIntegrationId
* @param integrationId id of the integration * @param integrationId id of the integration

View File

@@ -13,12 +13,13 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { openHecConnectDialog } from "../integration-dialog"; import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationCardComponent } from "./integration-card.component"; import { IntegrationCardComponent } from "./integration-card.component";
jest.mock("../integration-dialog", () => ({ jest.mock("../integration-dialog", () => ({
openHecConnectDialog: jest.fn(), openHecConnectDialog: jest.fn(),
HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" },
})); }));
describe("IntegrationCardComponent", () => { describe("IntegrationCardComponent", () => {
@@ -272,7 +273,7 @@ describe("IntegrationCardComponent", () => {
it("should call updateHec if isUpdateAvailable is true", async () => { it("should call updateHec if isUpdateAvailable is true", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({ (openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({ closed: of({
success: true, success: HecConnectDialogResultStatus.Edited,
url: "test-url", url: "test-url",
bearerToken: "token", bearerToken: "token",
index: "index", index: "index",
@@ -304,7 +305,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({ (openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({ closed: of({
success: true, success: HecConnectDialogResultStatus.Edited,
url: "test-url", url: "test-url",
bearerToken: "token", bearerToken: "token",
index: "index", index: "index",
@@ -327,10 +328,66 @@ describe("IntegrationCardComponent", () => {
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
}); });
it("should show toast on error", async () => { it("should call deleteHec when a delete is requested", async () => {
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({ (openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({ closed: of({
success: true, success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
mockIntegrationService.deleteHec.mockResolvedValue(undefined);
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
);
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
});
it("should not call deleteHec if no existing configuration", async () => {
component.integrationSettings = {
organizationIntegration: null,
name: OrganizationIntegrationServiceType.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
mockIntegrationService.deleteHec.mockResolvedValue(undefined);
await component.setupConnection();
expect(mockIntegrationService.deleteHec).not.toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
);
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
});
it("should show toast on error while saving", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
url: "test-url", url: "test-url",
bearerToken: "token", bearerToken: "token",
index: "index", index: "index",
@@ -349,5 +406,28 @@ describe("IntegrationCardComponent", () => {
message: mockI18nService.t("failedToSaveIntegration"), message: mockI18nService.t("failedToSaveIntegration"),
}); });
}); });
it("should show toast on error while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.deleteHec.mockRejectedValue(new Error("fail"));
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("failedToDeleteIntegration"),
});
});
}); });
}); });

View File

@@ -21,7 +21,11 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { openHecConnectDialog } from "../integration-dialog/index"; import {
HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog,
} from "../integration-dialog/index";
@Component({ @Component({
selector: "app-integration-card", selector: "app-integration-card",
@@ -142,32 +146,20 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
} }
try { try {
if (this.isUpdateAvailable) { if (result.success === HecConnectDialogResultStatus.Delete) {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; await this.deleteHec();
const orgIntegrationConfigurationId = }
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; } catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
if (!orgIntegrationId || !orgIntegrationConfigurationId) { try {
throw Error("Organization Integration ID or Configuration ID is missing"); if (result.success === HecConnectDialogResultStatus.Edited) {
} await this.saveHec(result);
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 { } catch {
this.toastService.showToast({ this.toastService.showToast({
@@ -175,7 +167,55 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
title: "", title: "",
message: this.i18nService.t("failedToSaveIntegration"), message: this.i18nService.t("failedToSaveIntegration"),
}); });
return;
} }
} }
async saveHec(result: HecConnectDialogResult) {
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
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");
}
// update existing integration and configuration
await this.hecOrganizationIntegrationService.updateHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
} else {
// create new integration and configuration
await this.hecOrganizationIntegrationService.saveHec(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
}
}
async deleteHec() {
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.deleteHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
}
} }

View File

@@ -37,6 +37,19 @@
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading"> <button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }} {{ "cancel" | i18n }}
</button> </button>
@if (canDelete) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
label="'delete' | i18n"
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
></button>
</div>
}
</ng-container> </ng-container>
</bit-dialog> </bit-dialog>
</form> </form>

View File

@@ -14,6 +14,7 @@ import {
ConnectHecDialogComponent, ConnectHecDialogComponent,
HecConnectDialogParams, HecConnectDialogParams,
HecConnectDialogResult, HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog, openHecConnectDialog,
} from "./connect-dialog-hec.component"; } from "./connect-dialog-hec.component";
@@ -65,7 +66,6 @@ describe("ConnectDialogHecComponent", () => {
imageDarkMode: "test-image-dark.png", imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31", newBadgeExpiration: "2024-12-31",
description: "Test Description", description: "Test Description",
isConnected: false,
canSetupConnection: true, canSetupConnection: true,
type: IntegrationType.EVENT, type: IntegrationType.EVENT,
} as Integration; } as Integration;
@@ -155,8 +155,7 @@ describe("ConnectDialogHecComponent", () => {
bearerToken: "token", bearerToken: "token",
index: "1", index: "1",
service: "Test Service", service: "Test Service",
success: true, success: HecConnectDialogResultStatus.Edited,
error: null,
}); });
}); });
}); });

View File

@@ -17,10 +17,17 @@ export interface HecConnectDialogResult {
bearerToken: string; bearerToken: string;
index: string; index: string;
service: string; service: string;
success: boolean; success: HecConnectDialogResultStatusType | null;
error: string | null;
} }
export const HecConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type HecConnectDialogResultStatusType =
(typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus];
@Component({ @Component({
templateUrl: "./connect-dialog-hec.component.html", templateUrl: "./connect-dialog-hec.component.html",
imports: [SharedModule], imports: [SharedModule],
@@ -40,6 +47,7 @@ export class ConnectHecDialogComponent implements OnInit {
@Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams, @Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams,
protected formBuilder: FormBuilder, protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HecConnectDialogResult>, private dialogRef: DialogRef<HecConnectDialogResult>,
private dialogService: DialogService,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -62,23 +70,51 @@ export class ConnectHecDialogComponent implements OnInit {
return !!this.hecConfig; return !!this.hecConfig;
} }
submit = async (): Promise<void> => { get canDelete(): boolean {
const formJson = this.formGroup.getRawValue(); return !!this.hecConfig;
}
const result: HecConnectDialogResult = { submit = async (): Promise<void> => {
integrationSettings: this.connectInfo.settings, if (this.formGroup.invalid) {
url: formJson.url || "", this.formGroup.markAllAsTouched();
bearerToken: formJson.bearerToken || "", return;
index: formJson.index || "", }
service: formJson.service || "", const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited);
success: true,
error: null,
};
this.dialogRef.close(result); this.dialogRef.close(result);
return; return;
}; };
delete = async (): Promise<void> => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: "deleteItemConfirmation",
},
type: "warning",
});
if (confirmed) {
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHecConnectDialogResult(
status: HecConnectDialogResultStatusType,
): HecConnectDialogResult {
const formJson = this.formGroup.getRawValue();
return {
integrationSettings: this.connectInfo.settings,
url: formJson.url || "",
bearerToken: formJson.bearerToken || "",
index: formJson.index || "",
service: formJson.service || "",
success: status,
};
}
} }
export function openHecConnectDialog( export function openHecConnectDialog(

View File

@@ -253,7 +253,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT, type: IntegrationType.EVENT,
description: "crowdstrikeEventIntegrationDesc", description: "crowdstrikeEventIntegrationDesc",
isConnected: false,
canSetupConnection: true, canSetupConnection: true,
}; };
@@ -265,6 +264,11 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.hecOrganizationIntegrationService.integrations$ this.hecOrganizationIntegrationService.integrations$
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((integrations) => { .subscribe((integrations) => {
// reset all integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
i.organizationIntegration = null;
});
integrations.map((integration) => { integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType); const item = this.integrationsList.find((i) => i.name === integration.serviceType);
if (item) { if (item) {