UI Zendesk integrations (#5356)

This commit is contained in:
RachelElysia 2022-05-10 22:33:30 -04:00 committed by GitHub
parent f893a9f8a9
commit 34a2d3e483
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 626 additions and 270 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

View file

@ -0,0 +1 @@
* UI allows for integration with Zendesk as well as Jira to manage software vulnerabilities

View file

@ -4,30 +4,59 @@ export interface IJiraIntegration {
api_token: string;
project_key: string;
enable_software_vulnerabilities?: boolean;
index?: number;
}
export interface IJiraIntegrationIndexed extends IJiraIntegration {
index: number;
}
export interface IJiraIntegrationFormData {
export interface IZendeskIntegration {
url: string;
username: string;
email: string;
api_token: string;
group_id: number;
enable_software_vulnerabilities?: boolean;
}
export interface IIntegration {
url: string;
username?: string;
email?: string;
api_token: string;
project_key?: string;
group_id?: number;
enable_software_vulnerabilities?: boolean;
originalIndex?: number;
type?: string;
tableIndex?: number;
dropdownIndex?: number;
name?: string;
}
export interface IIntegrationFormData {
url: string;
username?: string;
email?: string;
apiToken: string;
projectKey: string;
projectKey?: string;
groupId?: number;
enableSoftwareVulnerabilities?: boolean;
}
export interface IJiraIntegrationFormErrors {
export interface IIntegrationTableData extends IIntegrationFormData {
originalIndex: number;
type: string;
tableIndex?: number;
name: string;
}
export interface IIntegrationFormErrors {
url?: string | null;
email?: string | null;
username?: string | null;
apiToken?: string | null;
groupId?: number | null;
projectKey?: string | null;
enableSoftwareVulnerabilities?: boolean;
}
export interface IIntegrations {
zendesk: IZendeskIntegration[];
jira: IJiraIntegration[];
}
export type IIntegration = IJiraIntegration;

View file

@ -1,19 +1,21 @@
import React, { useState, useContext, useCallback } from "react";
import { useQuery } from "react-query";
import memoize from "memoize-one";
import { NotificationContext } from "context/notification";
import { IConfig } from "interfaces/config";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
IJiraIntegrationFormErrors,
IZendeskIntegration,
IIntegration,
IIntegrationTableData,
IIntegrations,
} from "interfaces/integration";
import { IApiError } from "interfaces/errors";
import Button from "components/buttons/Button";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import { DEFAULT_CREATE_INTEGRATION_ERRORS } from "utilities/constants";
import configAPI from "services/entities/config";
@ -25,7 +27,7 @@ import EditIntegrationModal from "./components/EditIntegrationModal";
import {
generateTableHeaders,
generateDataSet,
combineDataSets,
} from "./IntegrationsTableConfig";
const baseClass = "integrations-management";
@ -34,9 +36,9 @@ const noIntegrationsClass = "no-integrations";
const VALIDATION_FAILED_ERROR =
"There was a problem with the information you provided.";
const BAD_REQUEST_ERROR =
"Invalid login credentials or Jira URL. Please correct and try again.";
"Invalid login credentials or URL. Please correct and try again.";
const UNKNOWN_ERROR =
"We experienced an error when attempting to connect to Jira. Please try again later.";
"We experienced an error when attempting to connect. Please try again later.";
const IntegrationsPage = (): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
@ -51,17 +53,16 @@ const IntegrationsPage = (): JSX.Element => {
const [
integrationEditing,
setIntegrationEditing,
] = useState<IJiraIntegrationIndexed>();
const [integrationsIndexed, setIntegrationsIndexed] = useState<
IJiraIntegrationIndexed[]
] = useState<IIntegrationTableData>();
const [jiraIntegrations, setJiraIntegrations] = useState<
IJiraIntegration[]
>();
const [zendeskIntegrations, setZendeskIntegrations] = useState<
IZendeskIntegration[]
>();
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
const [
createIntegrationError,
setCreateIntegrationError,
] = useState<IJiraIntegrationFormErrors>(DEFAULT_CREATE_INTEGRATION_ERRORS);
const [testingConnection, setTestingConnection] = useState<boolean>(false);
const {
@ -69,26 +70,26 @@ const IntegrationsPage = (): JSX.Element => {
isLoading: isLoadingIntegrations,
error: loadingIntegrationsError,
refetch: refetchIntegrations,
} = useQuery<IConfig, Error, IJiraIntegration[]>(
} = useQuery<IConfig, Error, IIntegrations>(
["integrations"],
() => configAPI.loadAll(),
{
select: (data: IConfig) => {
return data.integrations.jira;
return data.integrations;
},
onSuccess: (data) => {
if (data) {
const addIndex = data.map((integration, index) => {
return { ...integration, index };
});
setIntegrationsIndexed(addIndex);
} else {
setIntegrationsIndexed([]);
setJiraIntegrations(data.jira);
setZendeskIntegrations(data.zendesk);
}
},
}
);
const combineJiraAndZendesk = memoize(() => {
return combineDataSets(jiraIntegrations || [], zendeskIntegrations || []);
});
const toggleAddIntegrationModal = useCallback(() => {
setShowAddIntegrationModal(!showAddIntegrationModal);
setBackendValidators({});
@ -99,7 +100,7 @@ const IntegrationsPage = (): JSX.Element => {
]);
const toggleDeleteIntegrationModal = useCallback(
(integration?: IJiraIntegrationIndexed) => {
(integration?: IIntegrationTableData) => {
setShowDeleteIntegrationModal(!showDeleteIntegrationModal);
integration
? setIntegrationEditing(integration)
@ -113,7 +114,7 @@ const IntegrationsPage = (): JSX.Element => {
);
const toggleEditIntegrationModal = useCallback(
(integration?: IJiraIntegrationIndexed) => {
(integration?: IIntegrationTableData) => {
setShowEditIntegrationModal(!showEditIntegrationModal);
setBackendValidators({});
integration
@ -129,27 +130,29 @@ const IntegrationsPage = (): JSX.Element => {
);
const onCreateSubmit = useCallback(
(jiraIntegrationSubmitData: IJiraIntegration[]) => {
(integrationSubmitData: IIntegration[], integrationDestination: string) => {
// Updates either integrations.jira or integrations.zendesk
const destination = () => {
if (integrationDestination === "jira") {
return { jira: integrationSubmitData, zendesk: zendeskIntegrations };
}
return { zendesk: integrationSubmitData, jira: jiraIntegrations };
};
setTestingConnection(true);
configAPI
.update({ integrations: { jira: jiraIntegrationSubmitData } })
.update({ integrations: destination() })
.then(() => {
renderFlash(
"success",
<>
Successfully added{" "}
<b>
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].url
}{" "}
-{" "}
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].project_key
}
{integrationSubmitData[integrationSubmitData.length - 1].url} -{" "}
{integrationSubmitData[integrationSubmitData.length - 1]
.project_key ||
integrationSubmitData[integrationSubmitData.length - 1]
.group_id}
</b>
</>
);
@ -172,16 +175,14 @@ const IntegrationsPage = (): JSX.Element => {
Could not add add{" "}
<b>
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].url
integrationSubmitData[integrationSubmitData.length - 1]
.url
}{" "}
-{" "}
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].project_key
}
{integrationSubmitData[integrationSubmitData.length - 1]
.project_key ||
integrationSubmitData[integrationSubmitData.length - 1]
.group_id}
</b>
. This integration already exists
</>
@ -197,11 +198,7 @@ const IntegrationsPage = (): JSX.Element => {
<>
Could not add{" "}
<b>
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].url
}
{integrationSubmitData[integrationSubmitData.length - 1].url}
</b>
. Please try again.
</>
@ -218,16 +215,35 @@ const IntegrationsPage = (): JSX.Element => {
const onDeleteSubmit = useCallback(() => {
if (integrationEditing) {
integrations?.splice(integrationEditing.index, 1);
configAPI
.update({ integrations: { jira: integrations } })
const deleteIntegrationDestination = () => {
if (integrationEditing.type === "jira") {
integrations?.jira.splice(integrationEditing.originalIndex, 1);
return configAPI.update({
integrations: {
jira: integrations?.jira,
zendesk: zendeskIntegrations,
},
});
}
integrations?.zendesk.splice(integrationEditing.originalIndex, 1);
return configAPI.update({
integrations: {
zendesk: integrations?.zendesk,
jira: jiraIntegrations,
},
});
};
deleteIntegrationDestination()
.then(() => {
renderFlash(
"success",
<>
Successfully deleted{" "}
<b>
{integrationEditing.url} - {integrationEditing.project_key}
{integrationEditing.url} -{" "}
{integrationEditing.projectKey ||
integrationEditing.groupId?.toString()}
</b>
</>
);
@ -239,7 +255,9 @@ const IntegrationsPage = (): JSX.Element => {
<>
Could not delete{" "}
<b>
{integrationEditing.url} - {integrationEditing.project_key}
{integrationEditing.url} -{" "}
{integrationEditing.projectKey ||
integrationEditing.groupId?.toString()}
</b>
. Please try again.
</>
@ -252,22 +270,40 @@ const IntegrationsPage = (): JSX.Element => {
}, [integrationEditing, toggleDeleteIntegrationModal]);
const onEditSubmit = useCallback(
(jiraIntegrationSubmitData: IJiraIntegration[]) => {
(integrationSubmitData: IIntegration[]) => {
if (integrationEditing) {
setTestingConnection(true);
configAPI
.update({ integrations: { jira: jiraIntegrationSubmitData } })
const editIntegrationDestination = () => {
if (integrationEditing.type === "jira") {
return configAPI.update({
integrations: {
jira: integrationSubmitData,
zendesk: zendeskIntegrations,
},
});
}
return configAPI.update({
integrations: {
zendesk: integrationSubmitData,
jira: jiraIntegrations,
},
});
};
editIntegrationDestination()
.then(() => {
renderFlash(
"success",
<>
Successfully edited{" "}
<b>
{jiraIntegrationSubmitData[integrationEditing?.index].url} -{" "}
{
jiraIntegrationSubmitData[integrationEditing?.index]
.project_key
}
{integrationSubmitData[integrationEditing?.originalIndex].url}{" "}
-{" "}
{integrationSubmitData[integrationEditing?.originalIndex]
.project_key ||
integrationSubmitData[integrationEditing?.originalIndex]
.group_id}
</b>
</>
);
@ -292,7 +328,8 @@ const IntegrationsPage = (): JSX.Element => {
Could not edit{" "}
<b>
{integrationEditing?.url} -{" "}
{integrationEditing?.project_key}
{integrationEditing?.projectKey ||
integrationEditing?.groupId?.toString()}
</b>
. Please try again.
</>
@ -309,7 +346,7 @@ const IntegrationsPage = (): JSX.Element => {
const onActionSelection = (
action: string,
integration: IJiraIntegrationIndexed
integration: IIntegrationTableData
): void => {
switch (action) {
case "edit":
@ -357,9 +394,8 @@ const IntegrationsPage = (): JSX.Element => {
};
const tableHeaders = generateTableHeaders(onActionSelection);
const tableData = integrationsIndexed
? generateDataSet(integrationsIndexed)
: [];
const tableData = combineJiraAndZendesk();
return (
<div className={`${baseClass}`}>
@ -377,8 +413,8 @@ const IntegrationsPage = (): JSX.Element => {
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
actionButtonText={"Add integration"}
hideActionButton={!tableData?.length}
actionButtonVariant={"brand"}
hideActionButton={!integrations || integrations.length === 0}
onActionButtonClick={toggleAddIntegrationModal}
resultsTitle={"integrations"}
emptyComponent={NoIntegrationsComponent}
@ -392,7 +428,7 @@ const IntegrationsPage = (): JSX.Element => {
onCancel={toggleAddIntegrationModal}
onSubmit={onCreateSubmit}
backendValidators={backendValidators}
integrations={integrations || []}
integrations={integrations || { jira: [], zendesk: [] }}
testingConnection={testingConnection}
/>
)}
@ -401,15 +437,19 @@ const IntegrationsPage = (): JSX.Element => {
onCancel={toggleDeleteIntegrationModal}
onSubmit={onDeleteSubmit}
url={integrationEditing?.url || ""}
projectKey={integrationEditing?.project_key || ""}
projectKey={
integrationEditing?.projectKey ||
integrationEditing?.groupId?.toString() ||
""
}
/>
)}
{showEditIntegrationModal && (
{showEditIntegrationModal && integrations && (
<EditIntegrationModal
onCancel={toggleEditIntegrationModal}
onSubmit={onEditSubmit}
backendValidators={backendValidators}
integrations={integrations || []}
integrations={integrations}
integrationEditing={integrationEditing}
testingConnection={testingConnection}
/>

View file

@ -5,11 +5,13 @@ import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
IZendeskIntegration,
IIntegrationTableData as IIntegrationCompleteData,
} from "interfaces/integration";
import { IDropdownOption } from "interfaces/dropdownOption";
import JiraIcon from "../../../../assets/images/icon-jira-24x24@2x.png";
import ZendeskIcon from "../../../../assets/images/icon-zendesk-32x24@2x.png";
interface IHeaderProps {
column: {
@ -20,7 +22,7 @@ interface IHeaderProps {
interface IRowProps {
row: {
original: IJiraIntegrationIndexed;
original: IIntegrationTableData;
};
}
interface ICellProps extends IRowProps {
@ -47,7 +49,7 @@ interface IDataColumn {
sortType?: string;
}
export interface IIntegrationTableData extends IJiraIntegration {
export interface IIntegrationTableData extends IIntegrationCompleteData {
actions: IDropdownOption[];
name: string;
}
@ -57,7 +59,7 @@ export interface IIntegrationTableData extends IJiraIntegration {
const generateTableHeaders = (
actionSelectHandler: (
value: string,
integration: IJiraIntegrationIndexed
integration: IIntegrationTableData
) => void
): IDataColumn[] => {
return [
@ -66,8 +68,20 @@ const generateTableHeaders = (
Header: "",
disableSortBy: true,
sortType: "caseInsensitive",
accessor: "logo",
Cell: () => <img src={JiraIcon} alt="jira-icon" />,
accessor: "type",
Cell: (cellProps: ICellProps) => {
return (
<div className={"logo-cell"}>
<img
src={cellProps.cell.value === "jira" ? JiraIcon : ZendeskIcon}
alt="integration-icon"
className={
cellProps.cell.value === "jira" ? "jira-icon" : "zendesk-icon"
}
/>
</div>
);
},
},
{
title: "Name",
@ -108,28 +122,55 @@ const generateActionDropdownOptions = (): IDropdownOption[] => {
];
};
const enhanceIntegrationData = (
integrations: IJiraIntegrationIndexed[]
const enhanceJiraData = (
jiraIntegrations: IJiraIntegration[]
): IIntegrationTableData[] => {
return Object.values(integrations).map((integration) => {
return jiraIntegrations.map((integration, index) => {
return {
url: integration.url,
username: integration.username,
api_token: integration.api_token,
project_key: integration.project_key,
actions: generateActionDropdownOptions(),
enable_software_vulnerabilities:
apiToken: integration.api_token,
projectKey: integration.project_key,
enableSoftwareVulnerabilities:
integration.enable_software_vulnerabilities,
name: `${integration.url} - ${integration.project_key}`,
index: integration.index,
actions: generateActionDropdownOptions(),
originalIndex: index,
type: "jira",
};
});
};
const generateDataSet = (
integrations: IJiraIntegrationIndexed[]
const enhanceZendeskData = (
zendeskIntegrations: IZendeskIntegration[]
): IIntegrationTableData[] => {
return [...enhanceIntegrationData(integrations)];
return zendeskIntegrations.map((integration, index) => {
return {
url: integration.url,
email: integration.email,
apiToken: integration.api_token,
groupId: integration.group_id,
enableSoftwareVulnerabilities:
integration.enable_software_vulnerabilities,
name: `${integration.url} - ${integration.group_id}`,
actions: generateActionDropdownOptions(),
originalIndex: index,
type: "zendesk",
};
});
};
export { generateTableHeaders, generateDataSet };
const combineDataSets = (
jiraIntegrations: IJiraIntegration[],
zendeskIntegrations: IZendeskIntegration[]
): IIntegrationTableData[] => {
const combine = [
...enhanceJiraData(jiraIntegrations),
...enhanceZendeskData(zendeskIntegrations),
];
return combine.map((integration, index) => {
return { ...integration, tableIndex: index };
});
};
export { generateTableHeaders, combineDataSets };

View file

@ -93,12 +93,21 @@
}
}
.logo__header {
width: 24px;
.type__header {
width: 12px;
}
.logo__cell {
img {
.type__cell {
.logo-cell {
display: block;
text-align: center;
}
.zendesk-icon {
width: 16px;
}
.jira-icon {
width: 24px;
}
}

View file

@ -1,24 +1,33 @@
import React, { useState, useEffect } from "react";
import Modal from "components/Modal";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import Spinner from "components/Spinner";
import { IJiraIntegration } from "interfaces/integration";
import { IIntegration, IIntegrations } from "interfaces/integration";
import IntegrationForm from "../IntegrationForm";
const baseClass = "create-integration-modal";
interface ICreateIntegrationModalProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
onSubmit: (
integrationSubmitData: IIntegration[],
integrationDestination: string
) => void;
serverErrors?: { base: string; email: string };
backendValidators: { [key: string]: string };
integrations: IJiraIntegration[];
integrations: IIntegrations;
testingConnection: boolean;
}
const destinationOptions = [
{ label: "Jira", value: "jira" },
{ label: "Zendesk", value: "zendesk" },
];
const CreateIntegrationModal = ({
onCancel,
onSubmit,
@ -29,6 +38,11 @@ const CreateIntegrationModal = ({
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
const [destination, setDestination] = useState<string>("jira");
const onDestinationChange = (value: string) => {
setDestination(value);
};
useEffect(() => {
setErrors(backendValidators);
@ -38,28 +52,35 @@ const CreateIntegrationModal = ({
<Modal title={"Add integration"} onExit={onCancel} className={baseClass}>
{testingConnection ? (
<div className={`${baseClass}__testing-connection`}>
<b>Testing connection to Jira</b>
<b>Testing connection</b>
<Spinner />
</div>
) : (
<>
<InfoBanner className={`${baseClass}__sandbox-info`}>
<p className={`${baseClass}__info-header`}>
Fleet supports Jira as a ticket destination.&nbsp;
<a
href="https://github.com/fleetdm/fleet/issues/new?assignees=&labels=idea&template=feature-request.md&title="
target="_blank"
rel="noopener noreferrer"
>
Suggest a new destination&nbsp;
<FleetIcon name="external-link" />
</a>
</p>
</InfoBanner>
<div className={`${baseClass}__info-header`}>
<Dropdown
label="Ticket destination"
name="destination"
onChange={onDestinationChange}
value={destination}
options={destinationOptions}
classname={`${baseClass}__destination-dropdown`}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<a
href="https://github.com/fleetdm/fleet/issues/new?assignees=&labels=idea&template=feature-request.md&title="
target="_blank"
rel="noopener noreferrer"
>
Suggest a new destination&nbsp;
<FleetIcon name="external-link" />
</a>
</div>
<IntegrationForm
onCancel={onCancel}
onSubmit={onSubmit}
integrations={integrations}
destination={destination}
/>
</>
)}

View file

@ -4,6 +4,7 @@
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
margin-top: $pad-small;
img {
width: 12px;
@ -12,12 +13,8 @@
}
}
&__sandbox-info {
margin: $pad-medium 0;
}
&__info-header {
margin: 0;
margin-bottom: $pad-xlarge;
}
&__btn-wrap {

View file

@ -3,8 +3,9 @@ import React, { useState, useEffect } from "react";
import Modal from "components/Modal";
import Spinner from "components/Spinner";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
IIntegration,
IIntegrations,
IIntegrationTableData,
} from "interfaces/integration";
import IntegrationForm from "../IntegrationForm";
@ -12,10 +13,10 @@ const baseClass = "edit-team-modal";
interface IEditIntegrationModalProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void;
backendValidators: { [key: string]: string };
integrations: IJiraIntegration[];
integrationEditing?: IJiraIntegrationIndexed;
integrations: IIntegrations;
integrationEditing?: IIntegrationTableData;
testingConnection: boolean;
}
@ -39,16 +40,33 @@ const EditIntegrationModal = ({
<Modal title={"Edit integration"} onExit={onCancel} className={baseClass}>
{testingConnection ? (
<div className={`${baseClass}__testing-connection`}>
<b>Testing connection to Jira</b>
<b>Testing connection</b>
<Spinner />
</div>
) : (
<IntegrationForm
onCancel={onCancel}
onSubmit={onSubmit}
integrations={integrations}
integrationEditing={integrationEditing}
/>
<>
<p>
<b>Ticket destination:</b>
<br />
{integrationEditing?.type === "jira" ? "Jira" : "Zendesk"}
</p>
<IntegrationForm
onCancel={onCancel}
onSubmit={onSubmit}
integrations={integrations}
integrationEditing={integrationEditing}
integrationEditingUrl={integrationEditing?.url || ""}
integrationEditingUsername={integrationEditing?.username || ""}
integrationEditingEmail={integrationEditing?.email || ""}
integrationEditingApiToken={integrationEditing?.apiToken || ""}
integrationEditingProjectKey={integrationEditing?.projectKey || ""}
integrationEditingGroupId={integrationEditing?.groupId || 0}
integrationEnableSoftwareVulnerabilities={
integrationEditing?.enableSoftwareVulnerabilities || false
}
integrationEditingType={integrationEditing?.type}
/>
</>
)}
</Modal>
);

View file

@ -1,10 +1,11 @@
import React, { FormEvent, useState } from "react";
import React, { FormEvent, useState, useEffect } from "react";
import ReactTooltip from "react-tooltip";
import {
IJiraIntegration,
IJiraIntegrationFormData,
IJiraIntegrationIndexed,
IIntegrationFormData,
IIntegrationTableData,
IIntegration,
IIntegrations,
} from "interfaces/integration";
import Button from "components/buttons/Button";
@ -15,9 +16,21 @@ const baseClass = "integration-form";
interface IIntegrationFormProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
integrationEditing?: IJiraIntegrationIndexed;
integrations: IJiraIntegration[];
onSubmit: (
untegrationSubmitData: IIntegration[],
integrationDestination: string
) => void;
integrationEditing?: IIntegrationTableData;
integrations: IIntegrations;
integrationEditingUrl?: string;
integrationEditingUsername?: string;
integrationEditingEmail?: string;
integrationEditingApiToken?: string;
integrationEditingProjectKey?: string;
integrationEditingGroupId?: number;
integrationEnableSoftwareVulnerabilities?: boolean;
integrationEditingType?: string;
destination?: string;
}
interface IFormField {
@ -30,18 +43,37 @@ const IntegrationForm = ({
onSubmit,
integrationEditing,
integrations,
integrationEditingUrl,
integrationEditingUsername,
integrationEditingEmail,
integrationEditingApiToken,
integrationEditingProjectKey,
integrationEditingGroupId,
integrationEnableSoftwareVulnerabilities,
integrationEditingType,
destination,
}: IIntegrationFormProps): JSX.Element => {
const [formData, setFormData] = useState<IJiraIntegrationFormData>({
url: integrationEditing?.url || "",
username: integrationEditing?.username || "",
apiToken: integrationEditing?.api_token || "",
projectKey: integrationEditing?.project_key || "",
const { jira: jiraIntegrations, zendesk: zendeskIntegrations } = integrations;
const [formData, setFormData] = useState<IIntegrationFormData>({
url: integrationEditingUrl || "",
username: integrationEditingUsername || "",
email: integrationEditingEmail || "",
apiToken: integrationEditingApiToken || "",
projectKey: integrationEditingProjectKey || "",
groupId: integrationEditingGroupId || 0,
enableSoftwareVulnerabilities:
integrationEditing?.enable_software_vulnerabilities || false,
integrationEnableSoftwareVulnerabilities || false,
});
const [integrationDestination, setIntegrationDestination] = useState<string>(
integrationEditingType || destination || "jira"
);
const [urlError, setUrlError] = useState<string | null>(null);
const { url, username, apiToken, projectKey } = formData;
useEffect(() => {
setIntegrationDestination(destination || integrationEditingType || "jira");
}, [destination, integrationEditingType]);
const { url, username, email, apiToken, projectKey, groupId } = formData;
const onInputChange = ({ name, value }: IFormField) => {
setFormData({ ...formData, [name]: value });
@ -57,38 +89,72 @@ const IntegrationForm = ({
setUrlError(error);
};
// IntegrationForm component can be used to create a new jira integration or edit an existing jira integration so submitData will be assembled accordingly
const createSubmitData = (): IJiraIntegration[] => {
let jiraIntegrationSubmitData = integrations;
// IntegrationForm component can be used to create a new integration or edit an existing integration so submitData will be assembled accordingly
const createSubmitData = (): IIntegration[] => {
let jiraIntegrationSubmitData = jiraIntegrations || [];
let zendeskIntegrationSubmitData = zendeskIntegrations || [];
if (integrationEditing) {
// Edit existing integration using array replacement
jiraIntegrationSubmitData.splice(integrationEditing.index, 1, {
// Editing through UI is temporarily deprecated in 4.14
if (integrationDestination === "jira") {
if (
integrationEditing &&
(integrationEditing.originalIndex ||
integrationEditing.originalIndex === 0) &&
integrationEditing.username
) {
// Edit existing jira integration using array replacement
jiraIntegrationSubmitData.splice(integrationEditing.originalIndex, 1, {
url,
username: username || "",
api_token: apiToken,
project_key: projectKey || "",
});
} else {
// Create new jira integration at end of array
jiraIntegrationSubmitData = [
...jiraIntegrationSubmitData,
{
url,
username: username || "",
api_token: apiToken,
project_key: projectKey || "",
},
];
}
return jiraIntegrationSubmitData;
}
if (
integrationEditing &&
(integrationEditing.originalIndex ||
integrationEditing.originalIndex === 0) &&
integrationEditing.email
) {
// Edit existing zendesk integration using array replacement
zendeskIntegrationSubmitData.splice(integrationEditing.originalIndex, 1, {
url,
username,
email: email || "",
api_token: apiToken,
project_key: projectKey,
group_id: groupId || 0,
});
} else {
// Create new integration at end of array
jiraIntegrationSubmitData = [
...jiraIntegrationSubmitData,
// Create new zendesk integration at end of array
zendeskIntegrationSubmitData = [
...zendeskIntegrationSubmitData,
{
url,
username,
email: email || "",
api_token: apiToken,
project_key: projectKey,
group_id: parseInt(groupId as any, 10) || 0,
},
];
}
return jiraIntegrationSubmitData;
return zendeskIntegrationSubmitData;
};
const onFormSubmit = (evt: FormEvent): void => {
evt.preventDefault();
return onSubmit(createSubmitData());
return onSubmit(createSubmitData(), integrationDestination);
};
return (
@ -101,62 +167,94 @@ const IntegrationForm = ({
autofocus
name="url"
onChange={onInputChange}
label="Jira site URL"
placeholder="https://jira.example.com"
label="URL"
placeholder={
integrationDestination === "jira"
? "https://example.atlassian.net"
: "https://example.zendesk.com"
}
parseTarget
value={url}
error={urlError}
onBlur={validateForm}
/>
<InputField
name="username"
onChange={onInputChange}
label="Jira username"
placeholder="name@example.com"
parseTarget
value={username}
tooltip={
"\
This user must have Create issues for the project <br/> \
in which the issues are created. \
"
}
/>
{integrationDestination === "jira" ? (
<InputField
name="username"
onChange={onInputChange}
label="Username"
placeholder="name@example.com"
parseTarget
value={username}
/>
) : (
<InputField
name="email"
onChange={onInputChange}
label="Email"
placeholder="name@example.com"
parseTarget
value={email}
/>
)}
<InputField
name="apiToken"
onChange={onInputChange}
label="Jira API token"
label="API token"
parseTarget
value={apiToken}
/>
<InputField
name="projectKey"
onChange={onInputChange}
label="Jira project key"
placeholder="JRAEXAMPLE"
parseTarget
value={projectKey}
tooltip={
"\
{integrationDestination === "jira" ? (
<InputField
name="projectKey"
onChange={onInputChange}
label="Project key"
placeholder="JRAEXAMPLE"
parseTarget
value={projectKey}
tooltip={
"\
To find the Jira project key, head to your project in <br /> \
Jira. Your project key is in URL. For example, in <br /> \
jira.example.com/projects/JRAEXAMPLE, <br /> \
JRAEXAMPLE is your project key. \
"
}
/>
}
/>
) : (
<InputField
name="groupId"
onChange={onInputChange}
label="Group ID"
placeholder="28134038"
type="number"
parseTarget
value={groupId === 0 ? null : groupId}
tooltip={
"\
To find the Zendesk group ID, select <b>Admin > <br /> \
People > Groups</b>. Find the group and select it. <br /> \
The group ID will appear in the search field. \
"
}
/>
)}
<div className={`${baseClass}__btn-wrap`}>
<div
data-tip
data-for="create-integration-button"
data-tip-disable={
!(
formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.username === "" ||
formData.apiToken === "" ||
formData.projectKey === ""
)
!(integrationDestination === "jira"
? formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.username === "" ||
formData.apiToken === "" ||
formData.projectKey === ""
: formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.email === "" ||
formData.apiToken === "" ||
formData.groupId === 0)
}
>
<Button
@ -164,11 +262,17 @@ const IntegrationForm = ({
type="submit"
variant="brand"
disabled={
formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.username === "" ||
formData.apiToken === "" ||
formData.projectKey === ""
integrationDestination === "jira"
? formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.username === "" ||
formData.apiToken === "" ||
formData.projectKey === ""
: formData.url === "" ||
formData.url.slice(0, 8) !== "https://" ||
formData.email === "" ||
formData.apiToken === "" ||
formData.groupId === 0
}
>
Save
@ -187,7 +291,7 @@ const IntegrationForm = ({
className={`tooltip`}
style={{ width: "152px", textAlign: "center" }}
>
Complete all fields to save the integration
Complete all fields to save the integration.
</div>
</ReactTooltip>
<Button

View file

@ -6,8 +6,12 @@ import { useDebouncedCallback } from "use-debounce";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IConfig } from "interfaces/config";
import { IJiraIntegration } from "interfaces/integration";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import {
IJiraIntegration,
IZendeskIntegration,
IIntegration,
} from "interfaces/integration";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; // @ts-ignore
import configAPI from "services/entities/config";
import softwareAPI, {
ISoftwareResponse,
@ -50,6 +54,7 @@ interface ISoftwareAutomations {
};
integrations: {
jira: IJiraIntegration[];
zendesk: IZendeskIntegration[];
};
}
interface IHeaderButtonsState extends ITeamsDropdownState {
@ -108,14 +113,24 @@ const ManageSoftwarePage = ({
let jiraIntegrationEnabled = false;
if (data.integrations.jira) {
jiraIntegrationEnabled = data?.integrations.jira.some(
(integration: any) => {
(integration: IIntegration) => {
return integration.enable_software_vulnerabilities;
}
);
}
let zendeskIntegrationEnabled = false;
if (data.integrations.zendesk) {
zendeskIntegrationEnabled = data?.integrations.zendesk.some(
(integration: IIntegration) => {
return integration.enable_software_vulnerabilities;
}
);
}
setIsVulnerabilityAutomationsEnabled(
data?.webhook_settings?.vulnerabilities_webhook
.enable_vulnerabilities_webhook || jiraIntegrationEnabled
.enable_vulnerabilities_webhook ||
jiraIntegrationEnabled ||
zendeskIntegrationEnabled
);
// Convert from nanosecond to nearest day
setRecentVulnerabilityMaxAge(
@ -260,6 +275,7 @@ const ManageSoftwarePage = ({
"success",
"Successfully updated vulnerability automations."
);
refetchSoftwareVulnerabilitiesWebhook();
});
} catch {
renderFlash(
@ -268,7 +284,6 @@ const ManageSoftwarePage = ({
);
} finally {
toggleManageAutomationsModal();
refetchSoftwareVulnerabilitiesWebhook();
}
};

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
@ -6,7 +6,9 @@ import PATHS from "router/paths";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
IZendeskIntegration,
IIntegration,
IIntegrations,
} from "interfaces/integration";
import { IConfig } from "interfaces/config";
import configAPI from "services/entities/config";
@ -33,6 +35,7 @@ interface ISoftwareAutomations {
};
integrations: {
jira: IJiraIntegration[];
zendesk: IZendeskIntegration[];
};
}
@ -78,16 +81,23 @@ const ManageAutomationsModal = ({
softwareAutomationsEnabled,
setSoftwareAutomationsEnabled,
] = useState<boolean>(softwareVulnerabilityAutomationEnabled || false);
const [jiraEnabled, setJiraEnabled] = useState<boolean>(
const [integrationEnabled, setIntegrationEnabled] = useState<boolean>(
!softwareVulnerabilityWebhookEnabled
);
const [integrationsIndexed, setIntegrationsIndexed] = useState<
IJiraIntegrationIndexed[]
const [jiraIntegrationsIndexed, setJiraIntegrationsIndexed] = useState<
IIntegration[]
>();
const [zendeskIntegrationsIndexed, setZendeskIntegrationsIndexed] = useState<
IIntegration[]
>();
const [allIntegrationsIndexed, setAllIntegrationsIndexed] = useState<
IIntegration[]
>();
const [
selectedIntegration,
setSelectedIntegration,
] = useState<IJiraIntegration>();
] = useState<IIntegration>();
useDeepEffect(() => {
setSoftwareAutomationsEnabled(
softwareVulnerabilityAutomationEnabled || false
@ -100,30 +110,63 @@ const ManageAutomationsModal = ({
}
}, [destinationUrl]);
const { data: integrations } = useQuery<IConfig, Error, IJiraIntegration[]>(
const { data: integrations } = useQuery<IConfig, Error, IIntegrations>(
["integrations"],
() => configAPI.loadAll(),
{
select: (data: IConfig) => {
return data.integrations.jira;
return data.integrations;
},
onSuccess: (data) => {
if (data) {
const addIndex = data.map((integration, index) => {
return { ...integration, index };
});
setIntegrationsIndexed(addIndex);
const currentSelectedJiraIntegration = addIndex.find(
(integration) => {
return integration.enable_software_vulnerabilities === true;
}
);
setSelectedIntegration(currentSelectedJiraIntegration);
}
// Set jira and zendesk integrations
const addJiraIndexed = data.jira
? data.jira.map((integration, index) => {
return { ...integration, originalIndex: index, type: "jira" };
})
: [];
setJiraIntegrationsIndexed(addJiraIndexed);
const addZendeskIndexed = data.zendesk
? data.zendesk.map((integration, index) => {
return {
...integration,
originalIndex: index,
type: "zendesk",
};
})
: [];
setZendeskIntegrationsIndexed(addZendeskIndexed);
},
}
);
useEffect(() => {
if (jiraIntegrationsIndexed && zendeskIntegrationsIndexed) {
const combineDataSets = jiraIntegrationsIndexed.concat(
zendeskIntegrationsIndexed
);
setAllIntegrationsIndexed(
combineDataSets?.map((integration, index) => {
return { ...integration, dropdownIndex: index };
})
);
}
}, [
jiraIntegrationsIndexed,
zendeskIntegrationsIndexed,
setAllIntegrationsIndexed,
]);
useEffect(() => {
if (allIntegrationsIndexed) {
const currentSelectedIntegration = allIntegrationsIndexed.find(
(integration) => {
return integration.enable_software_vulnerabilities === true;
}
);
setSelectedIntegration(currentSelectedIntegration);
}
}, [allIntegrationsIndexed]);
const onURLChange = (value: string) => {
setDestinationUrl(value);
};
@ -148,13 +191,16 @@ const ManageAutomationsModal = ({
},
},
integrations: {
jira: integrations || [],
jira: integrations?.jira || [],
zendesk: integrations?.zendesk || [],
},
};
const updateSoftwareAutomation = () => {
if (!softwareAutomationsEnabled) {
// set enable_vulnerabilities_webhook to false and all jira.enable_software_vulnerabilities to false
// set enable_vulnerabilities_webhook
// jira.enable_software_vulnerabilities
// and zendesk.enable_software_vulnerabilities to false
configSoftwareAutomations.webhook_settings.vulnerabilities_webhook.enable_vulnerabilities_webhook = false;
const disableAllJira = configSoftwareAutomations.integrations.jira.map(
(integration) => {
@ -162,13 +208,24 @@ const ManageAutomationsModal = ({
}
);
configSoftwareAutomations.integrations.jira = disableAllJira;
const disableAllZendesk = configSoftwareAutomations.integrations.zendesk.map(
(integration) => {
return {
...integration,
enable_software_vulnerabilities: false,
};
}
);
configSoftwareAutomations.integrations.zendesk = disableAllZendesk;
return;
}
if (!jiraEnabled) {
if (!integrationEnabled) {
if (!validUrl) {
return;
}
// set enable_vulnerabilities_webhook to true and all jira.enable_software_vulnerabilities to false
// set enable_vulnerabilities_webhook to true
// all jira.enable_software_vulnerabilities to false
// all zendesk.enable_software_vulnerabilities to false
configSoftwareAutomations.webhook_settings.vulnerabilities_webhook.enable_vulnerabilities_webhook = true;
const disableAllJira = configSoftwareAutomations.integrations.jira.map(
(integration) => {
@ -179,21 +236,46 @@ const ManageAutomationsModal = ({
}
);
configSoftwareAutomations.integrations.jira = disableAllJira;
const disableAllZendesk = configSoftwareAutomations.integrations.zendesk.map(
(integration) => {
return {
...integration,
enable_software_vulnerabilities: false,
};
}
);
configSoftwareAutomations.integrations.zendesk = disableAllZendesk;
return;
}
// set enable_vulnerabilities_webhook to false and all jira.enable_software_vulnerabilities to false
// except the one jira integration selected
// set enable_vulnerabilities_webhook to false
// all jira.enable_software_vulnerabilities to false
// all zendesk.enable_software_vulnerabilities to false
// except the one integration selected
configSoftwareAutomations.webhook_settings.vulnerabilities_webhook.enable_vulnerabilities_webhook = false;
const enableSelectedJiraIntegrationOnly = configSoftwareAutomations.integrations.jira.map(
(integration, index) => {
return {
...integration,
enable_software_vulnerabilities:
index === selectedIntegration?.index,
selectedIntegration?.type === "jira"
? index === selectedIntegration?.originalIndex
: false,
};
}
);
configSoftwareAutomations.integrations.jira = enableSelectedJiraIntegrationOnly;
const enableSelectedZendeskIntegrationOnly = configSoftwareAutomations.integrations.zendesk.map(
(integration, index) => {
return {
...integration,
enable_software_vulnerabilities:
selectedIntegration?.type === "zendesk"
? index === selectedIntegration?.originalIndex
: false,
};
}
);
configSoftwareAutomations.integrations.zendesk = enableSelectedZendeskIntegrationOnly;
};
updateSoftwareAutomation();
@ -202,10 +284,10 @@ const ManageAutomationsModal = ({
};
const createIntegrationDropdownOptions = () => {
const integrationOptions = integrationsIndexed?.map((i) => {
const integrationOptions = allIntegrationsIndexed?.map((i) => {
return {
value: String(i.index),
label: `${i.url} - ${i.project_key}`,
value: String(i.dropdownIndex),
label: `${i.url} - ${i.project_key || i.group_id}`,
};
});
return integrationOptions;
@ -213,17 +295,19 @@ const ManageAutomationsModal = ({
const onChangeSelectIntegration = (selectIntegrationIndex: string) => {
const integrationWithIndex:
| IJiraIntegrationIndexed
| undefined = integrationsIndexed?.find(
(integ: IJiraIntegrationIndexed) =>
integ.index === parseInt(selectIntegrationIndex, 10)
| IIntegration
| undefined = allIntegrationsIndexed?.find(
(integ: IIntegration) =>
integ.dropdownIndex === parseInt(selectIntegrationIndex, 10)
);
setSelectedIntegration(integrationWithIndex);
};
const onRadioChange = (jira: boolean): ((evt: string) => void) => {
const onRadioChange = (
enableIntegration: boolean
): ((evt: string) => void) => {
return () => {
setJiraEnabled(jira);
setIntegrationEnabled(enableIntegration);
};
};
@ -237,13 +321,15 @@ const ManageAutomationsModal = ({
{recentVulnerabilityMaxAge || "30"} days.
</p>
</div>
{integrationsIndexed && integrationsIndexed.length > 0 ? (
{(jiraIntegrationsIndexed && jiraIntegrationsIndexed.length > 0) ||
(zendeskIntegrationsIndexed &&
zendeskIntegrationsIndexed.length > 0) ? (
<Dropdown
searchable
options={createIntegrationDropdownOptions()}
onChange={onChangeSelectIntegration}
placeholder={"Select Jira integration"}
value={selectedIntegration?.index}
placeholder={"Select integration"}
value={selectedIntegration?.dropdownIndex}
label={"Integration"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
hint={
@ -333,7 +419,7 @@ const ManageAutomationsModal = ({
className={`${baseClass}__radio-input`}
label={"Ticket"}
id={"ticket-radio-btn"}
checked={jiraEnabled}
checked={integrationEnabled}
value={"ticket"}
name={"ticket"}
onChange={onRadioChange(true)}
@ -342,13 +428,13 @@ const ManageAutomationsModal = ({
className={`${baseClass}__radio-input`}
label={"Webhook"}
id={"webhook-radio-btn"}
checked={!jiraEnabled}
checked={!integrationEnabled}
value={"webhook"}
name={"webhook"}
onChange={onRadioChange(false)}
/>
</div>
{jiraEnabled ? renderTicket() : renderWebhook()}
{integrationEnabled ? renderTicket() : renderWebhook()}
</div>
{!softwareAutomationsEnabled && (
<div className={`${baseClass}__overlay`} />
@ -367,9 +453,11 @@ const ManageAutomationsModal = ({
data-for="save-automation-button"
data-tip-disable={
!(
integrationsIndexed &&
integrationsIndexed.length === 0 &&
jiraEnabled &&
((jiraIntegrationsIndexed &&
jiraIntegrationsIndexed.length === 0) ||
(zendeskIntegrationsIndexed &&
zendeskIntegrationsIndexed.length === 0)) &&
integrationEnabled &&
softwareAutomationsEnabled
)
}
@ -381,10 +469,10 @@ const ManageAutomationsModal = ({
onClick={handleSaveAutomation}
disabled={
(softwareAutomationsEnabled &&
jiraEnabled &&
integrationEnabled &&
!selectedIntegration) ||
(softwareAutomationsEnabled &&
!jiraEnabled &&
!integrationEnabled &&
destinationUrl === "")
}
>

View file

@ -340,13 +340,6 @@ export const DEFAULT_CREATE_USER_ERRORS = {
sso_enabled: null,
};
export const DEFAULT_CREATE_INTEGRATION_ERRORS = {
url: "",
username: "",
password: "",
projectKey: "",
};
export const DEFAULT_CREATE_LABEL_ERRORS = {
name: "",
};