UI: Settings > Integrations tab, Software Vulnerabilities Webhook v. Integration (#4874)

This commit is contained in:
RachelElysia 2022-04-11 15:04:41 -04:00 committed by GitHub
parent 7cb71bc5a8
commit d885758a6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1801 additions and 234 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
* Admin users can set jira integrations and software vulnerabilities to jira in the UI

View File

@ -197,12 +197,15 @@ describe(
cy.loginWithCySession("anna@organization.com", "user123#");
cy.visit("/software/manage");
});
it("allows global admin to update software vulnerability automation", () => {
it("allows global admin to create webhook software vulnerability automation", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", {
name: /manage automations/i,
}).click();
});
cy.getAttached(".manage-automations-modal").within(() => {
cy.getAttached(".fleet-slider").click();
cy.getAttached("#webhook-radio-btn").next().click();
});
cy.getAttached("#webhook-url").click().type("www.foo.com/bar");
cy.findByRole("button", { name: /^Save$/ }).click();
@ -215,6 +218,7 @@ describe(
});
cy.getAttached(".manage-automations-modal").within(() => {
cy.getAttached(".fleet-slider--active").should("exist");
cy.getAttached("#webhook-url").should("exist");
});
});
});

View File

@ -187,18 +187,18 @@ describe(
});
});
describe("Manage software page", () => {
beforeEach(() => {
cy.loginWithCySession("mary@organization.com", "user123#");
cy.visit("/software/manage");
});
it("allows maintainer to click 'Manage automations' button", () => {
it("manages software automations when all teams selected", () => {
beforeEach(() => cy.visit("/software/manage"));
it("should restrict global maintainer from 'Manage automations' button", () => {
it("hides manages software automations when all teams selected", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.findByText(/all teams/i).should("exist");
});
cy.findByRole("button", { name: /manage automations/i }).click();
cy.findByRole("button", { name: /cancel/i }).click();
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.findByRole("button", {
name: /manage automations/i,
}).should("not.exist");
});
});
});
it("hides manage automations button when all teams not selected", () => {

View File

@ -116,15 +116,13 @@ describe("Premium tier - Admin user", () => {
});
describe("Manage software page", () => {
beforeEach(() => cy.visit("/software/manage"));
it("allows global admin to update software vulnerability automation", () => {
it("allows global admin to create webhook software vulnerability automation", () => {
cy.getAttached(".manage-software-page__header-wrap").within(() => {
cy.getAttached(".Select").within(() => {
cy.findByText(/all teams/i).should("exist");
});
cy.findByRole("button", { name: /manage automations/i }).click();
});
cy.getAttached(".manage-automations-modal").within(() => {
cy.getAttached(".fleet-slider").click();
cy.getAttached("#webhook-radio-btn").next().click();
});
cy.getAttached("#webhook-url").click().type("www.foo.com/bar");
cy.findByRole("button", { name: /^Save$/ }).click();
@ -137,6 +135,7 @@ describe("Premium tier - Admin user", () => {
});
cy.getAttached(".manage-automations-modal").within(() => {
cy.getAttached(".fleet-slider--active").should("exist");
cy.getAttached("#webhook-url").should("exist");
});
});
it("hides manage automations button since all teams not selected", () => {

View File

@ -101,7 +101,7 @@ const EnrollSecretRow = ({
type={showSecret ? "text" : "password"}
value={secret.secret}
/>
{toggleSecretEditorModal && toggleDeleteSecretModal ? (
{toggleSecretEditorModal && toggleDeleteSecretModal && (
<>
<Button
onClick={onEditSecretClick}
@ -122,7 +122,7 @@ const EnrollSecretRow = ({
</>
</Button>
</>
) : null}
)}
</div>
);
};

View File

@ -13,8 +13,10 @@
color: $core-vibrant-red;
}
// so tooltips won't be triggered with whitespace
&[data-has-tooltip="true"] {
margin-bottom: $pad-small;
display: inline-flex;
}
}

View File

@ -6,8 +6,6 @@
.form-field__label {
font-size: $x-small;
font-weight: $bold;
// so tooltips won't be triggered with whitespace
display: inline-block;
}
&__email-hint {

View File

@ -39,7 +39,7 @@ import {
const baseClass = "app-config-form";
const AppConfigFormFunctional = ({
const AppConfigForm = ({
appConfig,
handleSubmit,
}: IAppConfigFormProps): JSX.Element => {
@ -977,4 +977,4 @@ const AppConfigFormFunctional = ({
);
};
export default AppConfigFormFunctional;
export default AppConfigForm;

View File

@ -10,7 +10,7 @@ type InitialStateType = {
notification: INotification | null;
renderFlash: (
alertType: "success" | "error" | "warning-filled" | null,
message: string | null,
message: JSX.Element | string | null,
undoAction?: (evt: React.MouseEvent<HTMLButtonElement>) => void
) => void;
hideFlash: () => void;
@ -57,7 +57,7 @@ const NotificationProvider = ({ children }: Props) => {
notification: state.notification,
renderFlash: (
alertType: "success" | "error" | "warning-filled" | null,
message: string | null,
message: JSX.Element | string | null,
undoAction?: (evt: React.MouseEvent<HTMLButtonElement>) => void
) => {
dispatch({

View File

@ -6,6 +6,7 @@ import {
IWebhookSoftwareVulnerabilities,
} from "interfaces/webhook";
import PropTypes from "prop-types";
import { IIntegrations } from "./integration";
export default PropTypes.shape({
org_name: PropTypes.string,
@ -178,6 +179,7 @@ export interface IConfig {
failing_policies_webhook: IWebhookFailingPolicies;
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
integrations: IIntegrations;
logging: {
debug: boolean;
json: boolean;

View File

@ -0,0 +1,33 @@
export interface IJiraIntegration {
url: string;
username: string;
password: string;
project_key: string;
enable_software_vulnerabilities?: boolean;
index?: number;
}
export interface IJiraIntegrationIndexed extends IJiraIntegration {
index: number;
}
export interface IJiraIntegrationFormData {
url: string;
username: string;
password: string;
projectKey: string;
enableSoftwareVulnerabilities?: boolean;
}
export interface IJiraIntegrationFormErrors {
url?: string | null;
username?: string | null;
password?: string | null;
projectKey?: string | null;
}
export interface IIntegrations {
jira: IJiraIntegration[];
}
export type IIntegration = IJiraIntegration;

View File

@ -59,10 +59,7 @@
}
}
// so tooltips won't be triggered with whitespace
.form-field__label {
display: inline-block;
.buttons {
top: 22px;
}

View File

@ -0,0 +1,373 @@
import React, { useState, useContext, useCallback } from "react";
import { useQuery } from "react-query";
import { NotificationContext } from "context/notification";
import { IConfig } from "interfaces/config";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
IJiraIntegrationFormErrors,
} 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";
import TableContainer from "components/TableContainer";
import TableDataError from "components/TableDataError";
import AddIntegrationModal from "./components/CreateIntegrationModal";
import DeleteIntegrationModal from "./components/DeleteIntegrationModal";
import EditIntegrationModal from "./components/EditIntegrationModal";
import {
generateTableHeaders,
generateDataSet,
} from "./IntegrationsTableConfig";
const baseClass = "integrations-management";
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.";
const UNKNOWN_ERROR =
"We experienced an error when attempting to connect to Jira. Please try again later.";
const IntegrationsPage = (): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const [showAddIntegrationModal, setShowAddIntegrationModal] = useState(false);
const [showDeleteIntegrationModal, setShowDeleteIntegrationModal] = useState(
false
);
const [showEditIntegrationModal, setShowEditIntegrationModal] = useState(
false
);
const [
integrationEditing,
setIntegrationEditing,
] = useState<IJiraIntegrationIndexed>();
const [integrationsIndexed, setIntegrationsIndexed] = useState<
IJiraIntegrationIndexed[]
>();
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
const [
createIntegrationError,
setCreateIntegrationError,
] = useState<IJiraIntegrationFormErrors>(DEFAULT_CREATE_INTEGRATION_ERRORS);
const [testingConnection, setTestingConnection] = useState<boolean>(false);
const {
data: integrations,
isLoading: isLoadingIntegrations,
error: loadingIntegrationsError,
refetch: refetchIntegrations,
} = useQuery<IConfig, Error, IJiraIntegration[]>(
["integrations"],
() => configAPI.loadAll(),
{
select: (data: IConfig) => {
return data.integrations.jira;
},
onSuccess: (data) => {
if (data) {
const addIndex = data.map((integration, index) => {
return { ...integration, index };
});
setIntegrationsIndexed(addIndex);
}
},
}
);
const toggleAddIntegrationModal = useCallback(() => {
setShowAddIntegrationModal(!showAddIntegrationModal);
setBackendValidators({});
}, [
showAddIntegrationModal,
setShowAddIntegrationModal,
setBackendValidators,
]);
const toggleDeleteIntegrationModal = useCallback(
(integration?: IJiraIntegrationIndexed) => {
setShowDeleteIntegrationModal(!showDeleteIntegrationModal);
integration
? setIntegrationEditing(integration)
: setIntegrationEditing(undefined);
},
[
showDeleteIntegrationModal,
setShowDeleteIntegrationModal,
setIntegrationEditing,
]
);
const toggleEditIntegrationModal = useCallback(
(integration?: IJiraIntegrationIndexed) => {
setShowEditIntegrationModal(!showEditIntegrationModal);
setBackendValidators({});
integration
? setIntegrationEditing(integration)
: setIntegrationEditing(undefined);
},
[
showEditIntegrationModal,
setShowEditIntegrationModal,
setIntegrationEditing,
setBackendValidators,
]
);
const onCreateSubmit = useCallback(
(jiraIntegrationSubmitData: IJiraIntegration[]) => {
setTestingConnection(true);
configAPI
.update({ integrations: { jira: jiraIntegrationSubmitData } })
.then(() => {
renderFlash(
"success",
<>
Successfully added{" "}
<b>
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].url
}
</b>
</>
);
setBackendValidators({});
toggleAddIntegrationModal();
refetchIntegrations();
})
.catch((createError: { data: IApiError }) => {
if (createError.data.message.includes("Validation Failed")) {
renderFlash("error", VALIDATION_FAILED_ERROR);
}
if (createError.data.message.includes("Bad request")) {
renderFlash("error", BAD_REQUEST_ERROR);
}
if (createError.data.message.includes("Unknown Error")) {
renderFlash("error", UNKNOWN_ERROR);
} else {
renderFlash(
"error",
<>
Could not add{" "}
<b>
{
jiraIntegrationSubmitData[
jiraIntegrationSubmitData.length - 1
].url
}
</b>
. Please try again.
</>
);
toggleAddIntegrationModal();
}
})
.finally(() => {
setTestingConnection(false);
});
},
[toggleAddIntegrationModal]
);
const onDeleteSubmit = useCallback(() => {
if (integrationEditing) {
integrations?.splice(integrationEditing.index, 1);
configAPI
.update({ integrations: { jira: integrations } })
.then(() => {
renderFlash(
"success",
<>
Successfully deleted <b>{integrationEditing.url}</b>
</>
);
})
.catch(() => {
renderFlash(
"error",
<>
Could not delete <b>{integrationEditing.url}</b>. Please try
again.
</>
);
})
.finally(() => {
refetchIntegrations();
toggleDeleteIntegrationModal();
});
}
}, [integrationEditing, toggleDeleteIntegrationModal]);
const onEditSubmit = useCallback(
(jiraIntegrationSubmitData: IJiraIntegration[]) => {
if (integrationEditing) {
setTestingConnection(true);
configAPI
.update({ integrations: { jira: jiraIntegrationSubmitData } })
.then(() => {
renderFlash(
"success",
<>
Successfully edited{" "}
<b>
{jiraIntegrationSubmitData[integrationEditing?.index].url}
</b>
</>
);
setBackendValidators({});
setTestingConnection(false);
setShowEditIntegrationModal(false);
refetchIntegrations();
})
.catch((editError: { data: IApiError }) => {
if (editError.data.message.includes("Validation Failed")) {
renderFlash("error", VALIDATION_FAILED_ERROR);
}
if (editError.data.message.includes("Bad request")) {
renderFlash("error", BAD_REQUEST_ERROR);
}
if (editError.data.message.includes("Unknown Error")) {
renderFlash("error", UNKNOWN_ERROR);
} else {
renderFlash(
"error",
<>
Could not edit <b>{integrationEditing?.url}</b>. Please try
again.
</>
);
}
})
.finally(() => {
setTestingConnection(false);
});
}
},
[integrationEditing, toggleEditIntegrationModal]
);
const onActionSelection = (
action: string,
integration: IJiraIntegrationIndexed
): void => {
switch (action) {
case "edit":
toggleEditIntegrationModal(integration);
break;
case "delete":
toggleDeleteIntegrationModal(integration);
break;
default:
}
};
const NoIntegrationsComponent = () => {
return (
<div className={`${noIntegrationsClass}`}>
<div className={`${noIntegrationsClass}__inner`}>
<div className={`${noIntegrationsClass}__inner-text`}>
<h1>Set up integrations</h1>
<p>
Create tickets automatically when Fleet detects new
vulnerabilities.
</p>
<p>
Want to learn more?&nbsp;
<a
href="https://fleetdm.com/docs/using-fleet/automations"
target="_blank"
rel="noopener noreferrer"
>
Read about automations&nbsp;
<FleetIcon name="external-link" />
</a>
</p>
<Button
variant="brand"
className={`${noIntegrationsClass}__create-button`}
onClick={toggleAddIntegrationModal}
>
Add integration
</Button>
</div>
</div>
</div>
);
};
const tableHeaders = generateTableHeaders(onActionSelection);
const tableData = integrationsIndexed
? generateDataSet(integrationsIndexed)
: [];
return (
<div className={`${baseClass}`}>
<p className={`${baseClass}__page-description`}>
Add or edit integrations to create tickets when Fleet detects new
vulnerabilities.
</p>
{loadingIntegrationsError ? (
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
data={tableData}
isLoading={isLoadingIntegrations}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
actionButtonText={"Add integration"}
actionButtonVariant={"brand"}
hideActionButton={!integrations || integrations.length === 0}
onActionButtonClick={toggleAddIntegrationModal}
resultsTitle={"integrations"}
emptyComponent={NoIntegrationsComponent}
showMarkAllPages={false}
isAllPagesSelected={false}
disablePagination
/>
)}
{showAddIntegrationModal && (
<AddIntegrationModal
onCancel={toggleAddIntegrationModal}
onSubmit={onCreateSubmit}
backendValidators={backendValidators}
integrations={integrations || []}
testingConnection={testingConnection}
/>
)}
{showDeleteIntegrationModal && (
<DeleteIntegrationModal
onCancel={toggleDeleteIntegrationModal}
onSubmit={onDeleteSubmit}
url={integrationEditing?.url || ""}
/>
)}
{showEditIntegrationModal && (
<EditIntegrationModal
onCancel={toggleEditIntegrationModal}
onSubmit={onEditSubmit}
backendValidators={backendValidators}
integrations={integrations || []}
integrationEditing={integrationEditing}
testingConnection={testingConnection}
/>
)}
</div>
);
};
export default IntegrationsPage;

View File

@ -0,0 +1,140 @@
import React from "react";
import TextCell from "components/TableContainer/DataTable/TextCell";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
} from "interfaces/integration";
import { IDropdownOption } from "interfaces/dropdownOption";
import JiraIcon from "../../../../assets/images/icon-jira-24x24@2x.png";
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
interface IRowProps {
row: {
original: IJiraIntegrationIndexed;
};
}
interface ICellProps extends IRowProps {
cell: {
value: string;
};
}
interface IDropdownCellProps extends IRowProps {
cell: {
value: IDropdownOption[];
};
}
interface IDataColumn {
title: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell:
| ((props: ICellProps) => JSX.Element)
| ((props: IDropdownCellProps) => JSX.Element);
disableHidden?: boolean;
disableSortBy?: boolean;
sortType?: string;
}
export interface IIntegrationTableData extends IJiraIntegration {
actions: IDropdownOption[];
name: string;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (
actionSelectHandler: (
value: string,
integration: IJiraIntegrationIndexed
) => void
): IDataColumn[] => {
return [
{
title: "",
Header: "",
disableSortBy: true,
sortType: "caseInsensitive",
accessor: "hosts",
Cell: () => <img src={JiraIcon} alt="jira-icon" />,
},
{
title: "Name",
Header: "Name",
disableSortBy: true,
sortType: "caseInsensitive",
accessor: "name",
Cell: (cellProps: ICellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Actions",
Header: "",
disableSortBy: true,
accessor: "actions",
Cell: (cellProps: IDropdownCellProps) => (
<DropdownCell
options={cellProps.cell.value}
onChange={(value: string) =>
actionSelectHandler(value, cellProps.row.original)
}
placeholder={"Actions"}
/>
),
},
];
};
// NOTE: may need current user ID later for permission on actions.
const generateActionDropdownOptions = (): IDropdownOption[] => {
return [
{
label: "Edit",
disabled: false,
value: "edit",
},
{
label: "Delete",
disabled: false,
value: "delete",
},
];
};
const enhanceIntegrationData = (
integrations: IJiraIntegrationIndexed[]
): IIntegrationTableData[] => {
return Object.values(integrations).map((integration) => {
return {
url: integration.url,
username: integration.username,
password: integration.password,
project_key: integration.project_key,
actions: generateActionDropdownOptions(),
enable_software_vulnerabilities:
integration.enable_software_vulnerabilities,
name: `${integration.url} - ${integration.project_key}`,
index: integration.index,
};
});
};
const generateDataSet = (
integrations: IJiraIntegrationIndexed[]
): IIntegrationTableData[] => {
return [...enhanceIntegrationData(integrations)];
};
export { generateTableHeaders, generateDataSet };

View File

@ -0,0 +1,116 @@
.integrations-management {
padding: 0;
&__page-description {
font-size: $x-small;
color: $core-fleet-black;
@include sticky-settings-description;
padding-bottom: $pad-medium;
}
}
.team-details__team-details {
.form-field--dropdown {
margin-bottom: 0;
}
}
.team-details.team-select-open {
.component__tabs-wrapper {
position: relative;
z-index: 2;
}
.members {
position: relative;
z-index: 1;
}
}
.no-integrations {
display: flex;
flex-direction: column;
align-items: center;
margin-top: $pad-xxxlarge;
a {
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
img {
width: 12px;
height: 12px;
margin-left: 7px;
}
}
h1 {
font-size: $large;
font-weight: $regular;
line-height: normal;
letter-spacing: normal;
color: $core-fleet-black;
}
h2 {
font-size: $small;
font-weight: $bold;
margin: 0 0 $pad-large;
line-height: 20px;
color: $core-fleet-black;
}
ul {
margin: 0;
padding: 0;
color: $core-fleet-black;
list-style: none;
li {
&::before {
content: "";
color: $core-vibrant-blue;
margin-right: $pad-medium;
}
}
}
&__inner {
display: flex;
flex-direction: row;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
img {
width: 176px;
margin-right: $pad-xlarge;
}
p {
color: $core-fleet-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
margin-bottom: $pad-large;
}
.no-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}
&__inner-text {
width: 350px;
}
}
.hosts__cell {
img {
width: 24px;
}
}

View File

@ -0,0 +1,70 @@
import React, { useState, useEffect } from "react";
import Modal from "components/Modal";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import Spinner from "components/Spinner";
import { IJiraIntegration } from "interfaces/integration";
import IntegrationForm from "../IntegrationForm";
const baseClass = "create-integration-modal";
interface ICreateIntegrationModalProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
serverErrors?: { base: string; email: string };
backendValidators: { [key: string]: string };
integrations: IJiraIntegration[];
testingConnection: boolean;
}
const CreateIntegrationModal = ({
onCancel,
onSubmit,
backendValidators,
integrations,
testingConnection,
}: ICreateIntegrationModalProps): JSX.Element => {
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
return (
<Modal title={"Add integration"} onExit={onCancel} className={baseClass}>
{testingConnection ? (
<div className={`${baseClass}__testing-connection`}>
<b>Testing connection to Jira</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>
<IntegrationForm
onCancel={onCancel}
onSubmit={onSubmit}
integrations={integrations}
/>
</>
)}
</Modal>
);
};
export default CreateIntegrationModal;

View File

@ -0,0 +1,32 @@
.create-integration-modal {
a {
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
img {
width: 12px;
height: 12px;
margin-left: 7px;
}
}
&__sandbox-info {
margin: $pad-medium 0;
}
&__info-header {
margin: 0;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./CreateIntegrationModal";

View File

@ -0,0 +1,62 @@
import React, { useEffect } from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "delete-integration-modal";
interface IDeleteIntegrationModalProps {
url: string;
onSubmit: () => void;
onCancel: () => void;
}
const DeleteIntegrationModal = ({
url,
onSubmit,
onCancel,
}: IDeleteIntegrationModalProps): JSX.Element => {
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (event.code === "Enter" || event.code === "NumpadEnter") {
event.preventDefault();
onSubmit();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
return (
<Modal title={"Delete integration"} onExit={onCancel} className={baseClass}>
<form className={`${baseClass}__form`}>
<p>
This action will delete the{" "}
<span className={`${baseClass}__url`}>{url}</span> integration.
</p>
<p>The automations that use this integration will be turned off.</p>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
type="button"
onClick={onSubmit}
variant="alert"
>
Delete
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse-alert"
>
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default DeleteIntegrationModal;

View File

@ -0,0 +1,15 @@
.delete-integration-modal {
&__url {
font-weight: $bold;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./DeleteIntegrationModal";

View File

@ -0,0 +1,58 @@
import React, { useState, useEffect } from "react";
import Modal from "components/Modal";
import Spinner from "components/Spinner";
// @ts-ignore
import {
IJiraIntegration,
IJiraIntegrationIndexed,
} from "interfaces/integration";
import IntegrationForm from "../IntegrationForm";
const baseClass = "edit-team-modal";
interface IEditIntegrationModalProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
backendValidators: { [key: string]: string };
integrations: IJiraIntegration[];
integrationEditing?: IJiraIntegrationIndexed;
testingConnection: boolean;
}
const EditIntegrationModal = ({
onCancel,
onSubmit,
backendValidators,
integrations,
integrationEditing,
testingConnection,
}: IEditIntegrationModalProps): JSX.Element => {
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
return (
<Modal title={"Edit integration"} onExit={onCancel} className={baseClass}>
{testingConnection ? (
<div className={`${baseClass}__testing-connection`}>
<b>Testing connection to Jira</b>
<Spinner />
</div>
) : (
<IntegrationForm
onCancel={onCancel}
onSubmit={onSubmit}
integrations={integrations}
integrationEditing={integrationEditing}
/>
)}
</Modal>
);
};
export default EditIntegrationModal;

View File

@ -0,0 +1,11 @@
.edit-team-modal {
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./EditIntegrationModal";

View File

@ -0,0 +1,190 @@
import React, { FormEvent, useState } from "react";
import ReactTooltip from "react-tooltip";
import {
IJiraIntegration,
IJiraIntegrationFormData,
IJiraIntegrationIndexed,
} from "interfaces/integration";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
const baseClass = "integration-form";
interface IIntegrationFormProps {
onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IJiraIntegration[]) => void;
integrationEditing?: IJiraIntegrationIndexed;
integrations: IJiraIntegration[];
}
interface IFormField {
name: string;
value: string;
}
const IntegrationForm = ({
onCancel,
onSubmit,
integrationEditing,
integrations,
}: IIntegrationFormProps): JSX.Element => {
const [formData, setFormData] = useState<IJiraIntegrationFormData>({
url: integrationEditing?.url || "",
username: integrationEditing?.username || "",
password: integrationEditing?.password || "",
projectKey: integrationEditing?.project_key || "",
enableSoftwareVulnerabilities:
integrationEditing?.enable_software_vulnerabilities || false,
});
const { url, username, password, projectKey } = formData;
const onInputChange = ({ name, value }: IFormField) => {
setFormData({ ...formData, [name]: value });
};
// 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;
if (integrationEditing) {
// Edit existing integration using array replacement
jiraIntegrationSubmitData.splice(integrationEditing.index, 1, {
url,
username,
password,
project_key: projectKey,
});
} else {
// Create new integration at end of array
jiraIntegrationSubmitData = [
...jiraIntegrationSubmitData,
{
url,
username,
password,
project_key: projectKey,
},
];
}
return jiraIntegrationSubmitData;
};
const onFormSubmit = (evt: FormEvent): void => {
evt.preventDefault();
return onSubmit(createSubmitData());
};
return (
<form
className={`${baseClass}__form`}
onSubmit={onFormSubmit}
autoComplete="off"
>
<InputField
autofocus
name="url"
onChange={onInputChange}
label="Jira site URL"
placeholder="https://jira.example.com"
parseTarget
value={url}
/>
<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. \
"
}
/>
<InputField
name="password"
onChange={onInputChange}
label="Jira password"
parseTarget
value={password}
/>
<InputField
name="projectKey"
onChange={onInputChange}
label="Jira 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. \
"
}
/>
<div className={`${baseClass}__btn-wrap`}>
<div
data-tip
data-for="create-integration-button"
data-tip-disable={
!(
formData.url === "" ||
formData.username === "" ||
formData.password === "" ||
formData.projectKey === ""
)
}
>
<Button
className={`${baseClass}__btn`}
type="submit"
variant="brand"
disabled={
formData.url === "" ||
formData.username === "" ||
formData.password === "" ||
formData.projectKey === ""
}
>
Save
</Button>
</div>{" "}
<ReactTooltip
className={`create-integration-tooltip`}
place="bottom"
type="dark"
effect="solid"
backgroundColor="#3e4771"
id="create-integration-button"
data-html
>
<div
className={`tooltip`}
style={{ width: "152px", textAlign: "center" }}
>
Complete all fields to save the integration
</div>
</ReactTooltip>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse"
>
Cancel
</Button>
</div>
</form>
);
};
export default IntegrationForm;

View File

@ -0,0 +1,135 @@
.integration-form {
a {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
}
img {
width: 12px;
height: 12px;
margin-left: 6px;
}
&__new-user-container {
margin-top: $pad-large;
margin-bottom: $pad-large;
}
&__sso-input {
margin-top: $pad-large;
margin-bottom: $pad-large;
.fleet-checkbox {
margin-top: 5px;
&__label {
font-size: $x-small;
font-weight: $bold;
color: $core-fleet-black;
}
}
}
.sso-disabled {
width: 375px;
}
.invite-disabled {
width: 100px;
}
.password-tooltip-text {
width: 310px;
}
.current-team {
margin-top: 4px;
margin-bottom: 24px;
}
&__label {
color: $core-fleet-black;
font-size: $x-small;
font-weight: $bold;
margin-bottom: $pad-small;
}
&__user-permissions-info {
display: flex;
flex-direction: column;
margin-bottom: $pad-xlarge;
p {
margin: 0 0 $pad-medium 0;
}
}
&__selected-teams-container {
margin-bottom: $pad-xxlarge;
}
&__radio-input {
margin-bottom: $pad-medium;
&.disabled {
.radio__control {
background-color: $ui-fleet-black-25;
}
.radio__label {
color: $ui-fleet-black-50;
font-style: italic;
}
}
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
}
&__btn {
margin-left: 12px;
}
&__invite-admin {
margin-bottom: 8px;
}
&__password {
width: 98%;
float: left;
padding: 0 $pad-medium 0 0;
box-sizing: border-box;
.input-icon-field {
width: 100%;
}
}
&__details {
float: right;
width: 2%;
.icon-tooltip {
margin-top: 12px;
}
.hint {
color: $core-fleet-black;
&--brand {
color: $core-vibrant-blue;
}
}
}
.sublabel {
margin: 0px;
}
&__tooltip-text {
width: 300px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./IntegrationForm";

View File

@ -0,0 +1 @@
export { default } from "./IntegrationsPage";

View File

@ -16,6 +16,10 @@ const settingsSubNav: ISettingSubNavItem[] = [
name: "Organization settings",
pathname: PATHS.ADMIN_SETTINGS,
},
{
name: "Integrations",
pathname: PATHS.ADMIN_INTEGRATIONS,
},
{
name: "Users",
pathname: PATHS.ADMIN_USERS,
@ -45,7 +49,7 @@ const SettingsWrapper = ({
}: ISettingsWrapperProp): JSX.Element => {
const { isPremiumTier } = useContext(AppContext);
if (isPremiumTier && settingsSubNav.length === 2) {
if (isPremiumTier && settingsSubNav.length === 3) {
settingsSubNav.push({
name: "Teams",
pathname: PATHS.ADMIN_TEAMS,

View File

@ -452,7 +452,7 @@ const MembersPage = ({
onCreateNewMember={toggleCreateMemberModal}
/>
) : null}
{showEditUserModal ? (
{showEditUserModal && (
<EditUserModal
editUserErrors={editUserErrors}
onCancel={toggleEditMemberModal}
@ -470,8 +470,8 @@ const MembersPage = ({
isModifiedByGlobalAdmin={isGlobalAdmin}
currentTeam={currentTeam}
/>
) : null}
{showCreateUserModal ? (
)}
{showCreateUserModal && (
<CreateUserModal
createUserErrors={createUserErrors}
onCancel={toggleCreateMemberModal}
@ -487,15 +487,15 @@ const MembersPage = ({
isModifiedByGlobalAdmin={isGlobalAdmin}
isFormSubmitting={isFormSubmitting}
/>
) : null}
{showRemoveMemberModal && currentTeam ? (
)}
{showRemoveMemberModal && currentTeam && (
<RemoveMemberModal
memberName={userEditing?.name || ""}
teamName={currentTeam.name}
onCancel={toggleRemoveMemberModal}
onSubmit={onRemoveMemberSubmit}
/>
) : null}
)}
</div>
);
};

View File

@ -248,28 +248,28 @@ const TeamManagementPage = (): JSX.Element => {
disablePagination
/>
)}
{showCreateTeamModal ? (
{showCreateTeamModal && (
<CreateTeamModal
onCancel={toggleCreateTeamModal}
onSubmit={onCreateSubmit}
backendValidators={backendValidators}
/>
) : null}
{showDeleteTeamModal ? (
)}
{showDeleteTeamModal && (
<DeleteTeamModal
onCancel={toggleDeleteTeamModal}
onSubmit={onDeleteSubmit}
name={teamEditing?.name || ""}
/>
) : null}
{showEditTeamModal ? (
)}
{showEditTeamModal && (
<EditTeamModal
onCancel={toggleEditTeamModal}
onSubmit={onEditSubmit}
defaultName={teamEditing?.name || ""}
backendValidators={backendValidators}
/>
) : null}
)}
</div>
);
};

View File

@ -132,9 +132,4 @@
&__tooltip-text {
width: 300px;
}
// so tooltips won't be triggered with whitespace
.form-field__label {
display: inline-block;
}
}

View File

@ -41,11 +41,6 @@
opacity: 75%;
}
// so tooltips won't be triggered with whitespace
.form-field__label {
display: inline-block;
}
.form-field__label--error {
color: $ui-error;
}

View File

@ -6,6 +6,7 @@ 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"; // @ts-ignore
import configAPI from "services/entities/config";
import softwareAPI, {
@ -42,6 +43,15 @@ interface IManageSoftwarePageProps {
search: string;
};
}
interface ISoftwareAutomations {
webhook_settings: {
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
integrations: {
jira: IJiraIntegration[];
};
}
interface IHeaderButtonsState extends ITeamsDropdownState {
isLoading: boolean;
}
@ -66,9 +76,9 @@ const ManageSoftwarePage = ({
const [isSoftwareEnabled, setIsSoftwareEnabled] = useState<boolean>();
const [
softwareVulnerabilitiesWebhook,
setSoftwareVulnerabilitiesWebhook,
] = useState<IWebhookSoftwareVulnerabilities>();
isVulnerabilityAutomationsEnabled,
setIsVulnerabilityAutomationsEnabled,
] = useState<boolean>();
const [filterVuln, setFilterVuln] = useState(
location?.query?.vulnerable || false
);
@ -91,6 +101,18 @@ const ManageSoftwarePage = ({
const { data: config } = useQuery(["config"], configAPI.loadAll, {
onSuccess: (data) => {
setIsSoftwareEnabled(data?.host_settings?.enable_software_inventory);
let jiraIntegrationEnabled = false;
if (data.integrations.jira) {
jiraIntegrationEnabled = data?.integrations.jira.some(
(integration: any) => {
return integration.enable_software_vulnerabilities;
}
);
}
setIsVulnerabilityAutomationsEnabled(
data?.webhook_settings?.vulnerabilities_webhook
.enable_vulnerabilities_webhook || jiraIntegrationEnabled
);
},
});
@ -168,18 +190,18 @@ const ManageSoftwarePage = ({
const canAddOrRemoveSoftwareWebhook = isGlobalAdmin || isGlobalMaintainer;
const { isLoading: isLoadingConfig, refetch: refetchConfig } = useQuery<
IConfig,
Error
>(["config"], () => configAPI.loadAll(), {
const {
data: softwareVulnerabilitiesWebhook,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
refetch: refetchSoftwareVulnerabilitiesWebhook,
} = useQuery<IConfig, Error, IWebhookSoftwareVulnerabilities>(
["config"],
() => configAPI.loadAll(),
{
enabled: canAddOrRemoveSoftwareWebhook,
onSuccess: (data) => {
setSoftwareVulnerabilitiesWebhook(
data.webhook_settings.vulnerabilities_webhook
select: (data: IConfig) => data.webhook_settings.vulnerabilities_webhook,
}
);
setConfig(data);
},
});
const onQueryChange = useDebouncedCallback(
async ({
@ -212,19 +234,11 @@ const ManageSoftwarePage = ({
toggleManageAutomationsModal();
};
const onCreateWebhookSubmit = async ({
destination_url,
enable_vulnerabilities_webhook,
}: IWebhookSoftwareVulnerabilities) => {
const onCreateWebhookSubmit = async (
configSoftwareAutomations: ISoftwareAutomations
) => {
try {
const request = configAPI.update({
webhook_settings: {
vulnerabilities_webhook: {
destination_url,
enable_vulnerabilities_webhook,
},
},
});
const request = configAPI.update(configSoftwareAutomations);
await request.then(() => {
renderFlash(
"success",
@ -238,7 +252,7 @@ const ManageSoftwarePage = ({
);
} finally {
toggleManageAutomationsModal();
refetchConfig();
refetchSoftwareVulnerabilitiesWebhook();
}
};
@ -250,7 +264,7 @@ const ManageSoftwarePage = ({
state: IHeaderButtonsState
): JSX.Element | null => {
if (
(state.isGlobalAdmin || state.isGlobalMaintainer) &&
state.isGlobalAdmin &&
(!state.isPremiumTier || state.teamId === 0) &&
!state.isLoading
) {
@ -297,12 +311,12 @@ const ManageSoftwarePage = ({
buttons={(state) =>
renderHeaderButtons({
...state,
isLoading: isLoadingConfig,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
})
}
/>
);
}, [router, location, isLoadingConfig]);
}, [router, location, isLoadingSoftwareVulnerabilitiesWebhook]);
const renderSoftwareCount = useCallback(() => {
const count = softwareCount;
@ -469,6 +483,9 @@ const ManageSoftwarePage = ({
onCreateWebhookSubmit={onCreateWebhookSubmit}
togglePreviewPayloadModal={togglePreviewPayloadModal}
showPreviewPayloadModal={showPreviewPayloadModal}
softwareVulnerabilityAutomationEnabled={
isVulnerabilityAutomationsEnabled
}
softwareVulnerabilityWebhookEnabled={
softwareVulnerabilitiesWebhook &&
softwareVulnerabilitiesWebhook.enable_vulnerabilities_webhook

View File

@ -1,22 +1,46 @@
import React, { useState } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
import PATHS from "router/paths";
import {
IJiraIntegration,
IJiraIntegrationIndexed,
} from "interfaces/integration";
import { IConfig } from "interfaces/config";
import configAPI from "services/entities/config";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import Slider from "components/forms/fields/Slider";
import Radio from "components/forms/fields/Radio";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import { useDeepEffect } from "utilities/hooks";
import { size } from "lodash";
import _, { size } from "lodash";
import PreviewPayloadModal from "../PreviewPayloadModal";
interface ISoftwareAutomations {
webhook_settings: {
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
integrations: {
jira: IJiraIntegration[];
};
}
interface IManageAutomationsModalProps {
onCancel: () => void;
onCreateWebhookSubmit: (formData: IWebhookSoftwareVulnerabilities) => void;
onCreateWebhookSubmit: (formData: ISoftwareAutomations) => void;
togglePreviewPayloadModal: () => void;
showPreviewPayloadModal: boolean;
softwareVulnerabilityAutomationEnabled?: boolean;
softwareVulnerabilityWebhookEnabled?: boolean;
currentDestinationUrl?: string;
}
@ -39,6 +63,7 @@ const ManageAutomationsModal = ({
onCreateWebhookSubmit,
togglePreviewPayloadModal,
showPreviewPayloadModal,
softwareVulnerabilityAutomationEnabled,
softwareVulnerabilityWebhookEnabled,
currentDestinationUrl,
}: IManageAutomationsModalProps): JSX.Element => {
@ -46,11 +71,20 @@ const ManageAutomationsModal = ({
currentDestinationUrl || ""
);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [
softwareAutomationsEnabled,
setSoftwareAutomationsEnabled,
] = useState<boolean>(softwareVulnerabilityWebhookEnabled || false);
] = useState<boolean>(softwareVulnerabilityAutomationEnabled || false);
const [jiraEnabled, setJiraEnabled] = useState<boolean>(
!softwareVulnerabilityWebhookEnabled
);
const [integrationsIndexed, setIntegrationsIndexed] = useState<
IJiraIntegrationIndexed[]
>();
const [
selectedIntegration,
setSelectedIntegration,
] = useState<IJiraIntegration>();
useDeepEffect(() => {
if (destination_url) {
@ -58,6 +92,30 @@ const ManageAutomationsModal = ({
}
}, [destination_url]);
const { data: integrations } = useQuery<IConfig, Error, IJiraIntegration[]>(
["integrations"],
() => configAPI.loadAll(),
{
select: (data: IConfig) => {
return data.integrations.jira;
},
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);
}
},
}
);
const onURLChange = (value: string) => {
setDestinationUrl(value);
};
@ -65,21 +123,175 @@ const ManageAutomationsModal = ({
const handleSaveAutomation = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateWebhookURL(destination_url);
const { valid: validUrl, errors: newErrors } = validateWebhookURL(
destination_url
);
setErrors({
...errors,
...newErrors,
});
// URL validation only needed if software automation is checked
if (valid || !softwareAutomationsEnabled) {
onCreateWebhookSubmit({
// Original config keys for software automation (webhook_settings, integrations)
const configSoftwareAutomations: ISoftwareAutomations = {
webhook_settings: {
vulnerabilities_webhook: {
destination_url,
enable_vulnerabilities_webhook: softwareAutomationsEnabled,
});
enable_vulnerabilities_webhook: softwareVulnerabilityWebhookEnabled,
},
},
integrations: {
jira: integrations || [],
},
};
onReturnToApp();
const updateSoftwareAutomation = () => {
if (!softwareAutomationsEnabled) {
// set enable_vulnerabilities_webhook to false and all jira.enable_software_vulnerabilities to false
configSoftwareAutomations.webhook_settings.vulnerabilities_webhook.enable_vulnerabilities_webhook = false;
const disableAllJira = configSoftwareAutomations.integrations.jira.map(
(integration) => {
return { ...integration, enable_software_vulnerabilities: false };
}
);
configSoftwareAutomations.integrations.jira = disableAllJira;
return;
}
if (!jiraEnabled) {
if (!validUrl) {
return;
}
// set enable_vulnerabilities_webhook to true and all jira.enable_software_vulnerabilities to false
configSoftwareAutomations.webhook_settings.vulnerabilities_webhook.enable_vulnerabilities_webhook = true;
const disableAllJira = configSoftwareAutomations.integrations.jira.map(
(integration) => {
return {
...integration,
enable_software_vulnerabilities: false,
};
}
);
configSoftwareAutomations.integrations.jira = disableAllJira;
return;
}
// set enable_vulnerabilities_webhook to false and all jira.enable_software_vulnerabilities to false
// except the one jira 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,
};
}
);
configSoftwareAutomations.integrations.jira = enableSelectedJiraIntegrationOnly;
};
updateSoftwareAutomation();
onCreateWebhookSubmit(configSoftwareAutomations);
onReturnToApp();
};
const createIntegrationDropdownOptions = () => {
const integrationOptions = integrationsIndexed?.map((i) => {
return {
value: String(i.index),
label: `${i.url} - ${i.project_key}`,
};
});
return integrationOptions;
};
const onChangeSelectIntegration = (selectIntegrationIndex: string) => {
const integrationWithIndex:
| IJiraIntegrationIndexed
| undefined = integrationsIndexed?.find(
(integ: IJiraIntegrationIndexed) =>
integ.index === parseInt(selectIntegrationIndex, 10)
);
setSelectedIntegration(integrationWithIndex);
};
const onRadioChange = (jira: boolean): ((evt: string) => void) => {
return () => {
setJiraEnabled(jira);
};
};
const renderTicket = () => {
return (
<div className={`${baseClass}__ticket`}>
<div className={`${baseClass}__software-automation-description`}>
<p>
A ticket will be created in your <b>Integration</b> if a detected
vulnerability (CVE) was published in the last 30 days.
</p>
</div>
{integrationsIndexed && integrationsIndexed.length > 0 ? (
<Dropdown
searchable
options={createIntegrationDropdownOptions()}
onChange={onChangeSelectIntegration}
placeholder={"Select Jira integration"}
value={selectedIntegration?.index}
label={"Integration"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
hint={
"For each new vulnerability detected, Fleet will create a ticket with a list of the affected hosts."
}
/>
) : (
<div className={`${baseClass}__no-integrations`}>
<div>
<b>You have no integrations.</b>
</div>
<div className={`${baseClass}__no-integration--cta`}>
<Link
to={PATHS.ADMIN_INTEGRATIONS}
className={`${baseClass}__add-integration-link`}
>
<span>Add integration</span>
</Link>
</div>
</div>
)}
</div>
);
};
const renderWebhook = () => {
return (
<div className={`${baseClass}__webhook`}>
<div className={`${baseClass}__software-automation-description`}>
<p>
A request will be sent to your configured <b>Destination URL</b> if
a detected vulnerability (CVE) was published in the last 30 days.
</p>
</div>
<InputField
inputWrapperClass={`${baseClass}__url-input`}
name="webhook-url"
label={"Destination URL"}
type={"text"}
value={destination_url}
onChange={onURLChange}
error={errors.url}
hint={
"For each new vulnerability detected, Fleet will send a JSON payload to this URL with a list of the affected hosts."
}
placeholder={"https://server.com/example"}
tooltip="Provide a URL to deliver a webhook request to."
/>
<Button
type="button"
variant="text-link"
onClick={togglePreviewPayloadModal}
>
Preview payload
</Button>
</div>
);
};
if (showPreviewPayloadModal) {
@ -105,34 +317,28 @@ const ManageAutomationsModal = ({
</div>
<div className={`${baseClass}__overlay-container`}>
<div className={`${baseClass}__software-automation-enabled`}>
<div className={`${baseClass}__software-automation-description`}>
<p>
A request will be sent to your configured <b>Destination URL</b>{" "}
if a detected vulnerability (CVE) was published in the last 30
days.
</p>
</div>
<InputField
inputWrapperClass={`${baseClass}__url-input`}
name="webhook-url"
label={"Destination URL"}
type={"text"}
value={destination_url}
onChange={onURLChange}
error={errors.url}
hint={
"For each new vulnerability detected, Fleet will send a JSON payload to this URL with a list of the affected hosts."
}
placeholder={"https://server.com/example"}
tooltip="Provide a URL to deliver a webhook request to."
<div className={`${baseClass}__workflow`}>
Workflow
<Radio
className={`${baseClass}__radio-input`}
label={"Ticket"}
id={"ticket-radio-btn"}
checked={jiraEnabled}
value={"ticket"}
name={"ticket"}
onChange={onRadioChange(true)}
/>
<Button
type="button"
variant="text-link"
onClick={togglePreviewPayloadModal}
>
Preview payload
</Button>
<Radio
className={`${baseClass}__radio-input`}
label={"Webhook"}
id={"webhook-radio-btn"}
checked={!jiraEnabled}
value={"webhook"}
name={"webhook"}
onChange={onRadioChange(false)}
/>
</div>
{jiraEnabled ? renderTicket() : renderWebhook()}
</div>
{!softwareAutomationsEnabled && (
<div className={`${baseClass}__overlay`} />
@ -151,6 +357,7 @@ const ManageAutomationsModal = ({
type="submit"
variant="brand"
onClick={handleSaveAutomation}
disabled={jiraEnabled && !selectedIntegration}
>
Save
</Button>

View File

@ -47,12 +47,32 @@
}
}
// so tooltips won't be triggered with whitespace
.form-field__label {
display: inline-block;
}
.form-field__label--error {
color: $ui-error;
}
&__workflow {
margin-top: $medium;
}
&__radio-input {
margin: $small 0;
}
&__no-integration--cta {
margin-top: $pad-medium;
}
&__add-integration-link {
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
margin-top: $pad-large;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
}

View File

@ -13,6 +13,7 @@ import { Provider } from "react-redux";
import { syncHistoryWithStore } from "react-router-redux";
import AdminAppSettingsPage from "pages/admin/AppSettingsPage";
import AdminIntegrationsPage from "pages/admin/IntegrationsPage";
import AdminUserManagementPage from "pages/admin/UserManagementPage";
import AdminTeamManagementPage from "pages/admin/TeamManagementPage";
import TeamDetailsWrapper from "pages/admin/TeamManagementPage/TeamDetailsWrapper";
@ -104,6 +105,10 @@ const routes = (
<Route component={SettingsWrapper}>
<Route component={AuthenticatedAdminRoutes}>
<Route path="organization" component={AdminAppSettingsPage} />
<Route
path="integrations"
component={AdminIntegrationsPage}
/>
<Route path="users" component={AdminUserManagementPage} />
<Route path="teams" component={AdminTeamManagementPage} />
</Route>

View File

@ -8,6 +8,7 @@ export default {
HOME: `${URL_PREFIX}/dashboard`,
ADMIN_USERS: `${URL_PREFIX}/settings/users`,
ADMIN_SETTINGS: `${URL_PREFIX}/settings/organization`,
ADMIN_INTEGRATIONS: `${URL_PREFIX}/settings/integrations`,
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,
ALL_PACKS: `${URL_PREFIX}/packs/all`,
EDIT_PACK: (packId: number): string => {

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import sendMockRequest from "services/mock_service";
import endpoints from "fleet/endpoints";
import { IConfig } from "interfaces/config";

View File

@ -19,35 +19,15 @@ const WILDCARDS: string[] = [":", "*", "{", "}"];
// REQUEST_RESPONSE_MAPPINGS dictionary maps your static responses to the specified API request path
const REQUEST_RESPONSE_MAPPINGS: IResponses = {
GET: {
// this is a basic path with no wildcards
"/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc":
RESPONSES.ALL_HOSTS,
// this basic path only matches with '1337' as the value for the team id query param
"/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id=1337":
RESPONSES.HOSTS_TEAM_1337,
// this wildcard path matches with any other value for the team id query param
"/hosts?page=0&per_page=100&order_key=hostname&order_direction=asc&team_id={team_id}":
RESPONSES.HOSTS_TEAM_ID,
// this basic path only matches with '1337' as the value for the host id route param
"/hosts/1337": RESPONSES.HOST_1337,
// this wildcard path matches with any other value for the host id route param
"/hosts/*id": RESPONSES.HOST_ID,
// this wildcard path matches with any value for the host id route param
"/hosts/:id/device_mapping": RESPONSES.DEVICE_MAPPING,
// this wildcard path matches with any value for the host id route param
"hosts/{*}/macadmins": RESPONSES.MACADMINS,
// this is a basic path with no wildcards
"hosts/count": {
count: 1,
},
// this wildcard path matches with any value for the team id route param
"hosts/count?team_id={*}": {
count: 1,
},
config: RESPONSES.config1, // just first integration -- to throw error, rename config as configz
},
// additional mappings can be specified for other HTTP request types (POST, PATCH, DELETE, etc.)
POST: {
"/:id/refetch": {}, // this wildcard route returns empty JSON
PATCH: {
config: RESPONSES.configAdd2, // will add second integration to first one
},
DELETE: {
// will remove second integration
config: RESPONSES.config1,
},
} as IResponses;

View File

@ -4,93 +4,185 @@
* Also please check the README for how to use the mock service :)
*/
const HOST_ID = {
host: {
created_at: "2021-03-31T00:00:00Z",
updated_at: "2021-03-31T00:00:00Z",
software: [],
id: 1337,
detail_updated_at: "2021-03-31T00:00:00Z",
label_updated_at: "2021-03-31T00:00:00Z",
policy_updated_at: "2021-03-31T00:00:00Z",
last_enrolled_at: "2021-03-31T00:00:00Z",
seen_time: "2021-03-31T00:00:00ZZ",
refetch_requested: false,
hostname: "myf1337d3v1c3",
uuid: "13371337-0000-0000-1337-133713371337",
platform: "rhel",
osquery_version: "5.1.0",
os_version: "Ubuntu 20.4.0",
build: "",
platform_like: "deb",
code_name: "",
uptime: 13371337133713371337,
memory: 143593800000,
cpu_type: "1337",
cpu_subtype: "1337",
cpu_brand: "Intel(R) Core(TM) i3-37k CPU @ 13.37GHz",
cpu_physical_cores: 8,
cpu_logical_cores: 8,
hardware_vendor: "",
hardware_model: "",
hardware_version: "",
hardware_serial: "",
computer_name: "myf1337d3v1c3",
primary_ip: "133.7.133.7",
primary_mac: "13:37:13:37:13:37",
distributed_interval: 1337,
config_tls_refresh: 1337,
logger_tls_period: 1337,
team_id: null,
pack_stats: [],
team_name: null,
users: [
{
uid: 1337,
username: "root",
type: "",
groupname: "root",
shell: "/bin/bash",
},
],
gigs_disk_space_available: 13.37,
percent_disk_space_available: 13.37,
issues: {
total_issues_count: 1337,
failing_policies_count: 1337,
},
labels: [],
packs: [],
policies: [],
status: "online",
display_text: "myf1337d3v1c3",
},
const mockJira0 = {
url: "https://example0.jira.com",
username: "adminUser",
password: "abc123",
project_key: "EXAMPLE",
enable_software_vulnerabilities: false,
};
const HOST_1337 = {
...HOST_ID,
team_id: 1337,
team_name: "h4x0r",
const mockJira1 = {
url: "https://example1.jira.com",
username: "adminUser",
password: "abc123",
project_key: "PROJECT",
enable_software_vulnerabilities: false,
};
const mockJira2 = {
url: "https://example2.jira.com",
username: "adminUser",
password: "abc123",
project_key: "KEY",
enable_software_vulnerabilities: true,
};
const mockIntegration1 = {
jira: [mockJira0, mockJira1],
};
const mockIntegration2 = {
jira: [mockJira2],
};
const mockIntegrationAdd2 = {
jira: [mockJira0, mockJira1, mockJira2],
};
const mockConfig = {
org_info: {
org_name: "s",
org_logo_url: "",
},
server_settings: {
server_url: "https://localhost:8080",
live_query_disabled: false,
enable_analytics: true,
deferred_save_host: false,
},
smtp_settings: {
enable_smtp: false,
configured: true,
sender_address: "",
server: "",
port: 0,
authentication_type: "authtype_none",
user_name: "",
password: "",
enable_ssl_tls: true,
authentication_method: "authmethod_plain",
domain: "",
verify_ssl_certs: true,
enable_start_tls: true,
},
host_expiry_settings: {
host_expiry_enabled: true,
host_expiry_window: 9,
},
host_settings: {
enable_host_users: true,
enable_software_inventory: true,
},
agent_options: {
config: {
options: {
logger_plugin: "tls",
pack_delimiter: "/",
logger_tls_period: 100,
distributed_plugin: "tls",
disable_distributed: false,
logger_tls_endpoint: "/api/v1/osquery/log",
distributed_interval: 10,
distributed_tls_max_attempts: 3,
},
decorators: {
load: [
"SELECT uuid AS host_uuid FROM system_info;",
"SELECT hostname AS hostname FROM system_info;",
],
},
},
overrides: {},
},
sso_settings: {
entity_id: "",
issuer_uri: "",
idp_image_url: "",
metadata: "",
metadata_url: "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
idp_name: "",
enable_sso: false,
enable_sso_idp_login: false,
},
vulnerability_settings: {
databases_path: "",
},
webhook_settings: {
host_status_webhook: {
enable_host_status_webhook: false,
destination_url: "",
host_percentage: 0,
days_count: 0,
},
failing_policies_webhook: {
enable_failing_policies_webhook: false,
destination_url: "",
policy_ids: [],
host_batch_size: 0,
},
vulnerabilities_webhook: {
enable_vulnerabilities_webhook: true,
destination_url: "www.example.com/",
host_batch_size: 0,
},
interval: "24h0m0s",
},
update_interval: {
osquery_detail: 10000000000,
osquery_policy: 3600000000000,
},
vulnerabilities: {
databases_path: "/tmp/vulndbs",
periodicity: 3600000000000,
cpe_database_url: "",
cve_feed_prefix_url: "",
current_instance_checks: "auto",
disable_data_sync: false,
},
license: {
tier: "premium",
organization: "development-only",
device_count: 100,
expiration: "2022-06-30T20:00:00-04:00",
note: "for development only",
},
logging: {
debug: false,
json: false,
result: {
plugin: "filesystem",
config: {
status_log_file:
"/var/folders/xh/bxm1d2615tv3vrg4zrxq540h0000gn/T/osquery_status",
result_log_file:
"/var/folders/xh/bxm1d2615tv3vrg4zrxq540h0000gn/T/osquery_result",
enable_log_rotation: false,
enable_log_compression: false,
},
},
status: {
plugin: "filesystem",
config: {
status_log_file:
"/var/folders/xh/bxm1d2615tv3vrg4zrxq540h0000gn/T/osquery_status",
result_log_file:
"/var/folders/xh/bxm1d2615tv3vrg4zrxq540h0000gn/T/osquery_result",
enable_log_rotation: false,
enable_log_compression: false,
},
},
},
};
export default {
ALL_HOSTS: {
hosts: [HOST_ID.host],
config1: { ...mockConfig, integrations: mockIntegration1 },
config2: {
...mockConfig,
integrations: mockIntegration2,
},
HOSTS_TEAM_ID: {
hosts: [{ ...HOST_ID.host, team_id: 2, team_name: "n00bz" }],
},
HOSTS_TEAM_1337: {
hosts: [HOST_1337.host],
},
HOST_ID,
HOST_1337,
DEVICE_MAPPING: {
host_id: 1337,
device_mapping: null,
foo: "bar",
},
MACADMINS: {
macadmins: null,
foo: "bar",
configAdd2: {
...mockConfig,
integrations: mockIntegrationAdd2,
},
};

View File

@ -340,6 +340,13 @@ 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: "",
};