diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 26b996662a59..03e555485166 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { integrations: { jira: [], zendesk: [], + google_calendar: [], }, logging: { debug: false, diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index 048dd6d49638..c66c58a0bc28 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { webhook: "Off", has_run: true, next_update_ms: 3600000, + calendar_events_enabled: true, }; const createMockPolicy = (overrides?: Partial): IPolicyStats => { diff --git a/frontend/components/forms/FormField/FormField.tsx b/frontend/components/forms/FormField/FormField.tsx index 68d07507a719..80bf5833a291 100644 --- a/frontend/components/forms/FormField/FormField.tsx +++ b/frontend/components/forms/FormField/FormField.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { isEmpty } from "lodash"; import TooltipWrapper from "components/TooltipWrapper"; +import { PlacesType } from "react-tooltip-5"; // all form-field styles are defined in _global.scss, which apply here and elsewhere const baseClass = "form-field"; @@ -16,6 +17,7 @@ export interface IFormFieldProps { name: string; type: string; tooltip?: React.ReactNode; + labelTooltipPosition?: PlacesType; } const FormField = ({ @@ -27,6 +29,7 @@ const FormField = ({ name, type, tooltip, + labelTooltipPosition, }: IFormFieldProps): JSX.Element => { const renderLabel = () => { const labelWrapperClasses = classnames(`${baseClass}__label`, { @@ -45,7 +48,10 @@ const FormField = ({ > {error || (tooltip ? ( - + {label as string} ) : ( diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 83c892eb162f..72969c256bf0 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -33,6 +33,7 @@ class InputField extends Component { ]).isRequired, parseTarget: PropTypes.bool, tooltip: PropTypes.string, + labelTooltipPosition: PropTypes.string, helpText: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), @@ -55,6 +56,7 @@ class InputField extends Component { value: "", parseTarget: false, tooltip: "", + labelTooltipPosition: "", helpText: "", enableCopy: false, ignore1password: false, @@ -124,6 +126,7 @@ class InputField extends Component { "error", "name", "tooltip", + "labelTooltipPosition", ]); const copyValue = (e) => { diff --git a/frontend/components/forms/fields/Slider/Slider.tsx b/frontend/components/forms/fields/Slider/Slider.tsx index 2b368275f1d9..db21fe6c9aa2 100644 --- a/frontend/components/forms/fields/Slider/Slider.tsx +++ b/frontend/components/forms/fields/Slider/Slider.tsx @@ -6,7 +6,10 @@ import FormField from "components/forms/FormField"; import { IFormFieldProps } from "components/forms/FormField/FormField"; interface ISliderProps { - onChange: () => void; + onChange: (newValue?: { + name: string; + value: string | number | boolean; + }) => void; value: boolean; inactiveText: string; activeText: string; diff --git a/frontend/components/graphics/CalendarEventPreview.tsx b/frontend/components/graphics/CalendarEventPreview.tsx new file mode 100644 index 000000000000..4c29770a6861 --- /dev/null +++ b/frontend/components/graphics/CalendarEventPreview.tsx @@ -0,0 +1,1184 @@ +import React from "react"; + +const CalendarEventPreview = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 💻 🚫  + + + + + + + + + + + + + + + + + 💻 🚫  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CalendarEventPreview; diff --git a/frontend/components/graphics/index.ts b/frontend/components/graphics/index.ts index 84e10a37113c..fb3b0c5fd4b1 100644 --- a/frontend/components/graphics/index.ts +++ b/frontend/components/graphics/index.ts @@ -17,6 +17,7 @@ import EmptyTeams from "./EmptyTeams"; import EmptyPacks from "./EmptyPacks"; import EmptySchedule from "./EmptySchedule"; import CollectingResults from "./CollectingResults"; +import CalendarEventPreview from "./CalendarEventPreview"; export const GRAPHIC_MAP = { // Empty state graphics @@ -41,6 +42,7 @@ export const GRAPHIC_MAP = { "file-pem": FilePem, // Other graphics "collecting-results": CollectingResults, + "calendar-event-preview": CalendarEventPreview, }; export type GraphicNames = keyof typeof GRAPHIC_MAP; diff --git a/frontend/hooks/useCheckboxListStateManagement.tsx b/frontend/hooks/useCheckboxListStateManagement.tsx new file mode 100644 index 000000000000..4c1cf9d88d6c --- /dev/null +++ b/frontend/hooks/useCheckboxListStateManagement.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +import { IPolicy } from "interfaces/policy"; + +interface ICheckedPolicy { + name?: string; + id: number; + isChecked: boolean; +} + +const useCheckboxListStateManagement = ( + allPolicies: IPolicy[], + automatedPolicies: number[] | undefined +) => { + const [policyItems, setPolicyItems] = useState(() => { + return allPolicies.map(({ name, id }) => ({ + name, + id, + isChecked: !!automatedPolicies?.includes(id), + })); + }); + + const updatePolicyItems = (policyId: number) => { + setPolicyItems((prevItems) => + prevItems.map((policy) => + policy.id !== policyId + ? policy + : { ...policy, isChecked: !policy.isChecked } + ) + ); + }; + + return { policyItems, updatePolicyItems }; +}; + +export default useCheckboxListStateManagement; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 1df44de33d8c..8eee167f1224 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -4,7 +4,7 @@ import { IWebhookFailingPolicies, IWebhookSoftwareVulnerabilities, } from "interfaces/webhook"; -import { IIntegrations } from "./integration"; +import { IGlobalIntegrations } from "./integration"; export interface ILicense { tier: string; @@ -175,7 +175,7 @@ export interface IConfig { // databases_path: string; // }; webhook_settings: IWebhookSettings; - integrations: IIntegrations; + integrations: IGlobalIntegrations; logging: { debug: boolean; json: boolean; diff --git a/frontend/interfaces/integration.ts b/frontend/interfaces/integration.ts index adcbeeb7e786..49156d6277f0 100644 --- a/frontend/interfaces/integration.ts +++ b/frontend/interfaces/integration.ts @@ -60,7 +60,31 @@ export interface IIntegrationFormErrors { enableSoftwareVulnerabilities?: boolean; } +export interface IGlobalCalendarIntegration { + email: string; + private_key: string; + domain: string; +} + +interface ITeamCalendarSettings { + enable_calendar_events: boolean; + webhook_url: string; +} + +// zendesk and jira fields are coupled – if one is present, the other needs to be present. If +// one is present and the other is null/missing, the other will be nullified. google_calendar is +// separated – it can be present without the other 2 without nullifying them. +// TODO: Update these types to reflect this. + export interface IIntegrations { zendesk: IZendeskIntegration[]; jira: IJiraIntegration[]; } + +export interface IGlobalIntegrations extends IIntegrations { + google_calendar?: IGlobalCalendarIntegration[] | null; +} + +export interface ITeamIntegrations extends IIntegrations { + google_calendar?: ITeamCalendarSettings | null; +} diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 4858de5f37c7..056ab704066c 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -40,6 +40,7 @@ export interface IPolicy { created_at: string; updated_at: string; critical: boolean; + calendar_events_enabled: boolean; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -90,6 +91,7 @@ export interface IPolicyFormData { query?: string | number | boolean | undefined; team_id?: number; id?: number; + calendar_events_enabled?: boolean; } export interface IPolicyNew { diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 435075902a16..8fa4726022af 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import { IConfigFeatures, IWebhookSettings } from "./config"; import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret"; -import { IIntegrations } from "./integration"; +import { ITeamIntegrations } from "./integration"; import { UserRole } from "./user"; export default PropTypes.shape({ @@ -82,7 +82,7 @@ export type ITeamWebhookSettings = Pick< */ export interface ITeamAutomationsConfig { webhook_settings: ITeamWebhookSettings; - integrations: IIntegrations; + integrations: ITeamIntegrations; } /** diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss index 1152dcfb5f80..06bd48a653fd 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss @@ -1,104 +1,9 @@ .host-actions-dropdown { - .form-field { - margin: 0; + @include button-dropdown; + .Select-multi-value-wrapper { + width: 55px; } - - .Select { - position: relative; - border: 0; - height: auto; - - &.is-focused, - &:hover { - border: 0; - } - - &.is-focused:not(.is-open) { - .Select-control { - background-color: initial; - } - } - - .Select-control { - display: flex; - background-color: initial; - height: auto; - justify-content: space-between; - border: 0; - cursor: pointer; - - &:hover { - box-shadow: none; - } - - &:hover .Select-placeholder { - color: $core-vibrant-blue; - } - - .Select-placeholder { - color: $core-fleet-black; - font-size: 14px; - line-height: normal; - padding-left: 0; - margin-top: 1px; - } - - .Select-input { - height: auto; - } - - .Select-arrow-zone { - display: flex; - } - } - - .Select-multi-value-wrapper { - width: 55px; - } - - .Select-placeholder { - display: flex; - align-items: center; - } - - .Select-menu-outer { - margin-top: $pad-xsmall; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); - border-radius: $border-radius; - z-index: 6; - overflow: hidden; - border: 0; - width: 188px; - left: unset; - top: unset; - max-height: none; - padding: $pad-small; - position: absolute; - left: -120px; - - .Select-menu { - max-height: none; - } - } - - .Select-arrow { - transition: transform 0.25s ease; - } - - &:not(.is-open) { - .Select-control:hover .Select-arrow { - content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); - } - } - - &.is-open { - .Select-control .Select-placeholder { - color: $core-vibrant-blue; - } - - .Select-arrow { - transform: rotate(180deg); - } - } + .Select > .Select-menu-outer { + left: -120px; } } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 99faef93dda8..a117974fa2d6 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -13,7 +13,6 @@ import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; import activitiesAPI, { - IActivitiesResponse, IPastActivitiesResponse, IUpcomingActivitiesResponse, } from "services/entities/activities"; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index ce359b577898..80a832026b41 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -2,7 +2,9 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import PATHS from "router/paths"; -import { noop, isEqual } from "lodash"; +import { noop, isEqual, uniqueId } from "lodash"; + +import { Tooltip as ReactTooltip5 } from "react-tooltip-5"; import { getNextLocationPath } from "utilities/helpers"; @@ -34,6 +36,8 @@ import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import Button from "components/buttons/Button"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; import RevealButton from "components/buttons/RevealButton"; import Spinner from "components/Spinner"; import TeamsDropdown from "components/TeamsDropdown"; @@ -44,6 +48,8 @@ import PoliciesTable from "./components/PoliciesTable"; import OtherWorkflowsModal from "./components/OtherWorkflowsModal"; import AddPolicyModal from "./components/AddPolicyModal"; import DeletePolicyModal from "./components/DeletePolicyModal"; +import CalendarEventsModal from "./components/CalendarEventsModal"; +import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal"; interface IManagePoliciesPageProps { router: InjectedRouter; @@ -125,12 +131,15 @@ const ManagePolicyPage = ({ const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false); const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false); + const [ + updatingPolicyEnabledCalendarEvents, + setUpdatingPolicyEnabledCalendarEvents, + ] = useState(false); const [selectedPolicyIds, setSelectedPolicyIds] = useState([]); - const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( - false - ); + const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false); const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); + const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false); const [teamPolicies, setTeamPolicies] = useState(); const [inheritedPolicies, setInheritedPolicies] = useState(); @@ -473,14 +482,30 @@ const ManagePolicyPage = ({ ] // Other dependencies can cause infinite re-renders as URL is source of truth ); - const toggleManageAutomationsModal = () => - setShowManageAutomationsModal(!showManageAutomationsModal); + const toggleOtherWorkflowsModal = () => + setShowOtherWorkflowsModal(!showOtherWorkflowsModal); const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal); const toggleDeletePolicyModal = () => setShowDeletePolicyModal(!showDeletePolicyModal); + const toggleCalendarEventsModal = () => { + setShowCalendarEventsModal(!showCalendarEventsModal); + }; + + const onSelectAutomationOption = (option: string) => { + switch (option) { + case "calendar_events": + toggleCalendarEventsModal(); + break; + case "other_workflows": + toggleOtherWorkflowsModal(); + break; + default: + } + }; + const toggleShowInheritedPolicies = () => { // URL source of truth const locationPath = getNextLocationPath({ @@ -496,6 +521,7 @@ const ManagePolicyPage = ({ const handleUpdateAutomations = async (requestBody: { webhook_settings: Pick; + // TODO - update below type to specify team integration integrations: IIntegrations; }) => { setIsUpdatingAutomations(true); @@ -510,13 +536,59 @@ const ManagePolicyPage = ({ "Could not update policy automations. Please try again." ); } finally { - toggleManageAutomationsModal(); + toggleOtherWorkflowsModal(); setIsUpdatingAutomations(false); refetchConfig(); isAnyTeamSelected && refetchTeamConfig(); } }; + const updatePolicyEnabledCalendarEvents = async ( + formData: ICalendarEventsFormData + ) => { + setUpdatingPolicyEnabledCalendarEvents(true); + + try { + // update enabled and URL in config + const configResponse = teamsAPI.update( + { + integrations: { + google_calendar: { + enable_calendar_events: formData.enabled, + webhook_url: formData.url, + }, + // TODO - can omit these? + zendesk: teamConfig?.integrations.zendesk || [], + jira: teamConfig?.integrations.jira || [], + }, + }, + teamIdForApi + ); + + // update policies calendar events enabled + // TODO - only update changed policies + const policyResponses = formData.policies.map((formPolicy) => + teamPoliciesAPI.update(formPolicy.id, { + calendar_events_enabled: formPolicy.isChecked, + team_id: teamIdForApi, + }) + ); + + await Promise.all([configResponse, ...policyResponses]); + renderFlash("success", "Successfully updated policy automations."); + } catch { + renderFlash( + "error", + "Could not update policy automations. Please try again." + ); + } finally { + toggleCalendarEventsModal(); + setUpdatingPolicyEnabledCalendarEvents(false); + refetchTeamPolicies(); + refetchTeamConfig(); + } + }; + const onAddPolicyClick = () => { setLastEditedQueryName(""); setLastEditedQueryDescription(""); @@ -682,6 +754,60 @@ const ManagePolicyPage = ({ ); }; + const getAutomationsDropdownOptions = () => { + const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1; + let calEventsLabel: React.ReactNode = "Calendar events"; + if (!isPremiumTier) { + const tipId = uniqueId(); + calEventsLabel = ( + +
Calendar events
+ + Available in Fleet Premium + +
+ ); + } else if (isAllTeams) { + const tipId = uniqueId(); + calEventsLabel = ( + +
Calendar events
+ + Select a team to manage +
+ calendar events. +
+
+ ); + } + + return [ + { + label: calEventsLabel, + value: "calendar_events", + disabled: !isPremiumTier || isAllTeams, + helpText: "Automatically reserve time to resolve failing policies.", + }, + { + label: "Other workflows", + value: "other_workflows", + disabled: false, + helpText: "Create tickets or fire webhooks for failing policies.", + }, + ]; + }; + + const isCalEventsConfigured = + (config?.integrations.google_calendar && + config?.integrations.google_calendar.length > 0) ?? + false; + return (
@@ -709,18 +835,15 @@ const ManagePolicyPage = ({ {showCtaButtons && (
{canManageAutomations && automationsConfig && ( - +
+ +
)} {canAddOrDeletePolicy && (
@@ -790,13 +913,13 @@ const ManagePolicyPage = ({ )}
)} - {config && automationsConfig && showManageAutomationsModal && ( + {config && automationsConfig && showOtherWorkflowsModal && ( )} @@ -815,6 +938,22 @@ const ManagePolicyPage = ({ onSubmit={onDeletePolicySubmit} /> )} + {showCalendarEventsModal && ( + + )}
); diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index 62c29c1d5b65..ed99ad013734 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -8,13 +8,33 @@ .button-wrap { display: flex; justify-content: flex-end; - min-width: 266px; + align-items: center; + gap: 8px; } } - &__manage-automations { - padding: $pad-small; - margin-right: $pad-small; + &__manage-automations-wrapper { + @include button-dropdown; + .Select-multi-value-wrapper { + width: 146px; + } + .Select > .Select-menu-outer { + left: -186px; + width: 360px; + .is-disabled * { + color: $ui-fleet-black-25; + .react-tooltip { + @include tooltip-text; + } + } + } + .Select-control { + margin-top: 0; + gap: 6px; + } + .Select-placeholder { + font-weight: $bold; + } } &__header { diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx new file mode 100644 index 000000000000..eba5abb4e0d1 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useState } from "react"; + +import { IPolicy } from "interfaces/policy"; + +import validURL from "components/forms/validators/valid_url"; + +import Button from "components/buttons/Button"; +import RevealButton from "components/buttons/RevealButton"; +import CustomLink from "components/CustomLink"; +import Slider from "components/forms/fields/Slider"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Graphic from "components/Graphic"; +import Modal from "components/Modal"; +import Checkbox from "components/forms/fields/Checkbox"; +import { syntaxHighlight } from "utilities/helpers"; + +const baseClass = "calendar-events-modal"; + +interface IFormPolicy { + name: string; + id: number; + isChecked: boolean; +} +export interface ICalendarEventsFormData { + enabled: boolean; + url: string; + policies: IFormPolicy[]; +} + +interface ICalendarEventsModal { + onExit: () => void; + updatePolicyEnabledCalendarEvents: ( + formData: ICalendarEventsFormData + ) => void; + isUpdating: boolean; + configured: boolean; + enabled: boolean; + url: string; + policies: IPolicy[]; +} + +// allows any policy name to be the name of a form field, one of the checkboxes +type FormNames = string; + +const CalendarEventsModal = ({ + onExit, + updatePolicyEnabledCalendarEvents, + isUpdating, + configured, + enabled, + url, + policies, +}: ICalendarEventsModal) => { + const [formData, setFormData] = useState({ + enabled, + url, + // TODO - stay udpdated on state of backend approach to syncing policies in the policies table + // and in the new calendar table + // id may change if policy was deleted + // name could change if policy was renamed + policies: policies.map((policy) => ({ + name: policy.name, + id: policy.id, + isChecked: policy.calendar_events_enabled || false, + })), + }); + const [formErrors, setFormErrors] = useState>( + {} + ); + const [showPreviewCalendarEvent, setShowPreviewCalendarEvent] = useState( + false + ); + const [showExamplePayload, setShowExamplePayload] = useState(false); + + const validateCalendarEventsFormData = ( + curFormData: ICalendarEventsFormData + ) => { + const errors: Record = {}; + if (curFormData.enabled) { + const { url: curUrl } = curFormData; + if (!validURL({ url: curUrl })) { + const errorPrefix = curUrl ? `${curUrl} is not` : "Please enter"; + errors.url = `${errorPrefix} a valid resolution webhook URL`; + } + } + return errors; + }; + + // TODO - separate change handlers for checkboxes: + // const onPolicyUpdate = ... + // const onTextFieldUpdate = ... + + const onInputChange = useCallback( + (newVal: { name: FormNames; value: string | number | boolean }) => { + const { name, value } = newVal; + let newFormData: ICalendarEventsFormData; + // for the first two fields, set the new value directly + if (["enabled", "url"].includes(name)) { + newFormData = { ...formData, [name]: value }; + } else if (typeof value === "boolean") { + // otherwise, set the value for a nested policy + const newFormPolicies = formData.policies.map((formPolicy) => { + if (formPolicy.name === name) { + return { ...formPolicy, isChecked: value }; + } + return formPolicy; + }); + newFormData = { ...formData, policies: newFormPolicies }; + } else { + throw TypeError("Unexpected value type for policy checkbox"); + } + setFormData(newFormData); + setFormErrors(validateCalendarEventsFormData(newFormData)); + }, + [formData] + ); + + const togglePreviewCalendarEvent = () => { + setShowPreviewCalendarEvent(!showPreviewCalendarEvent); + }; + + const renderExamplePayload = () => { + return ( + <> +
POST https://server.com/example
+
+      
+    );
+  };
+
+  const renderPolicies = () => {
+    return (
+      
+
Policies:
+ {formData.policies.map((policy) => { + const { isChecked, name, id } = policy; + return ( +
+ { + onInputChange({ name, value: !isChecked }); + }} + > + {name} + +
+ ); + })} + + A calendar event will be created for end users if one of their hosts + fail any of these policies.{" "} + + +
+ ); + }; + const renderPreviewCalendarEventModal = () => { + return ( + + <> +

A similar event will appear in the end user's calendar:

+ +
+ +
+ +
+ ); + }; + + const renderPlaceholderModal = () => { + return ( +
+ + + +
+ To create calendar events for end users if their hosts fail policies, + you must first connect Fleet to your Google Workspace service account. +
+
+ This can be configured in{" "} + Settings > Integrations > Calendars. +
+ +
+ +
+
+ ); + }; + + const renderConfiguredModal = () => ( +
+
+ { + onInputChange({ name: "enabled", value: !formData.enabled }); + }} + inactiveText="Disabled" + activeText="Enabled" + /> + +
+
+ + { + setShowExamplePayload(!showExamplePayload); + }} + /> + {showExamplePayload && renderExamplePayload()} + {renderPolicies()} +
+
+ + +
+
+ ); + + if (showPreviewCalendarEvent) { + return renderPreviewCalendarEventModal(); + } + return ( + { + updatePolicyEnabledCalendarEvents(formData); + } + : onExit + } + className={baseClass} + width="large" + > + {configured ? renderConfiguredModal() : renderPlaceholderModal()} + + ); +}; + +export default CalendarEventsModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss new file mode 100644 index 000000000000..3b1952a8c34c --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss @@ -0,0 +1,35 @@ +.calendar-events-modal { + .placeholder { + display: flex; + flex-direction: column; + gap: 24px; + line-height: 150%; + .modal-cta-wrap { + margin-top: 0; + } + } + .form-header { + display: flex; + justify-content: space-between; + .button--text-link { + white-space: nowrap; + } + } + + .form-fields { + &--disabled { + @include disabled; + } + } + + pre { + box-sizing: border-box; + margin: 0; + } +} + +.calendar-event-preview { + p { + margin: 24px 0; + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts new file mode 100644 index 000000000000..b08ecf1063e4 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CalendarEventsModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx index 4fc8f7973d59..c2c1c505a9f7 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx @@ -284,16 +284,6 @@ const generateTableHeaders = ( ]; if (tableType !== "inheritedPolicies") { - tableHeaders.push({ - title: "Automations", - Header: "Automations", - disableSortBy: true, - accessor: "webhook", - Cell: (cellProps: ICellProps): JSX.Element => ( - - ), - }); - if (!canAddOrDeletePolicy) { return tableHeaders; } diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index d7a03f1f1b98..2858938dc737 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -87,6 +87,7 @@ export default { resolution, platform, critical, + calendar_events_enabled, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -98,6 +99,7 @@ export default { resolution, platform, critical, + calendar_events_enabled, }); }, destroy: (teamId: number | undefined, ids: number[]) => { diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 3c7e1a26193b..149544582765 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -5,7 +5,7 @@ import { pick } from "lodash"; import { buildQueryStringFromParams } from "utilities/url"; import { IEnrollSecret } from "interfaces/enroll_secret"; -import { IIntegrations } from "interfaces/integration"; +import { ITeamIntegrations } from "interfaces/integration"; import { API_NO_TEAM_ID, INewTeamUsersBody, @@ -39,7 +39,7 @@ export interface ITeamFormData { export interface IUpdateTeamFormData { name: string; webhook_settings: Partial; - integrations: IIntegrations; + integrations: ITeamIntegrations; mdm: { macos_updates?: { minimum_version: string; @@ -118,7 +118,7 @@ export default { requestBody.webhook_settings = webhook_settings; } if (integrations) { - const { jira, zendesk } = integrations; + const { jira, zendesk, google_calendar } = integrations; const teamIntegrationProps = [ "enable_failing_policies", "group_id", @@ -128,6 +128,7 @@ export default { requestBody.integrations = { jira: jira?.map((j) => pick(j, teamIntegrationProps)), zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)), + google_calendar, }; } if (mdm) { diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index a600c5c848ac..a5c2d855466d 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -220,3 +220,103 @@ $max-width: 2560px; // compensate in layout for extra clickable area button height margin: -8px 0; } + +@mixin button-dropdown { + .form-field { + margin: 0; + } + + .Select { + position: relative; + border: 0; + height: auto; + + &.is-focused, + &:hover { + border: 0; + } + + &.is-focused:not(.is-open) { + .Select-control { + background-color: initial; + } + } + + .Select-control { + display: flex; + background-color: initial; + height: auto; + justify-content: space-between; + border: 0; + cursor: pointer; + + &:hover { + box-shadow: none; + } + + &:hover .Select-placeholder { + color: $core-vibrant-blue; + } + + .Select-placeholder { + color: $core-fleet-black; + font-size: 14px; + line-height: normal; + padding-left: 0; + margin-top: 1px; + } + + .Select-input { + height: auto; + } + + .Select-arrow-zone { + display: flex; + } + } + + .Select-placeholder { + display: flex; + align-items: center; + } + + .Select-menu-outer { + margin-top: $pad-xsmall; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + border-radius: $border-radius; + z-index: 6; + overflow: hidden; + border: 0; + width: 188px; + left: unset; + top: unset; + max-height: none; + padding: $pad-small; + position: absolute; + + .Select-menu { + max-height: none; + } + } + + .Select-arrow { + transition: transform 0.25s ease; + } + + &:not(.is-open) { + .Select-control:hover .Select-arrow { + content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); + } + } + + &.is-open { + .Select-control .Select-placeholder { + color: $core-vibrant-blue; + } + + .Select-arrow { + transform: rotate(180deg); + } + } + } +}