17445 calendar events modal (#17717)

Addresses #17445 

Follow-up iteration:
- Finalize styling of dropdown tooltips
- All `//TODO`s

<img width="1393" alt="Screenshot 2024-03-20 at 1 43 54 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/9b792cf0-058a-4ae6-8f5f-a49eb936ebef">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 01 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/86195dcf-ec28-4cf0-ab8b-d785d12372ed">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 21 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/01effdec-ca20-49ec-a442-5fe754a5e12b">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 26 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/b6de6891-6eae-426e-bbff-b01184094ac9">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 33 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/96e167dd-752c-4b49-a1a7-69fe9b4f42ac">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 43 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/feedbda5-e915-4e5e-84ee-2316db49434a">
<img width="1393" alt="Screenshot 2024-03-20 at 1 44 47 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/c4b5ac47-3357-43ef-95ca-dd0953994f6f">
<img width="1393" alt="Screenshot 2024-03-20 at 1 45 02 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/17838415-5bf4-46f0-9bde-522deb0f0886">
<img width="1393" alt="Screenshot 2024-03-20 at 1 45 10 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/b7228484-bb9f-4119-9fbf-a60ce990ba0e">

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2024-03-20 13:53:34 -07:00 committed by Victor Lyuboslavsky
parent 4db06f2cbb
commit 5137fe380c
No known key found for this signature in database
23 changed files with 1917 additions and 145 deletions

View file

@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
integrations: {
jira: [],
zendesk: [],
google_calendar: [],
},
logging: {
debug: false,

View file

@ -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>): IPolicyStats => {

View file

@ -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 ? (
<TooltipWrapper tipContent={tooltip}>
<TooltipWrapper
tipContent={tooltip}
position={labelTooltipPosition || "top-start"}
>
{label as string}
</TooltipWrapper>
) : (

View file

@ -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) => {

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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<ICheckedPolicy[]>(() => {
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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;
}
/**

View file

@ -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;
}
}

View file

@ -13,7 +13,6 @@ import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import activitiesAPI, {
IActivitiesResponse,
IPastActivitiesResponse,
IUpcomingActivitiesResponse,
} from "services/entities/activities";

View file

@ -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<number[]>([]);
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<IPolicyStats[]>();
const [inheritedPolicies, setInheritedPolicies] = useState<IPolicyStats[]>();
@ -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<IWebhookSettings, "failing_policies_webhook">;
// 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 = (
<span>
<div data-tooltip-id={tipId}>Calendar events</div>
<ReactTooltip5 id={tipId} place="left">
Available in Fleet Premium
</ReactTooltip5>
</span>
);
} else if (isAllTeams) {
const tipId = uniqueId();
calEventsLabel = (
<span>
<div data-tooltip-id={tipId}>Calendar events</div>
<ReactTooltip5
id={tipId}
place="left"
positionStrategy="fixed"
disableStyleInjection
offset={5}
>
Select a team to manage
<br />
calendar events.
</ReactTooltip5>
</span>
);
}
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 (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
@ -709,18 +835,15 @@ const ManagePolicyPage = ({
{showCtaButtons && (
<div className={`${baseClass} button-wrap`}>
{canManageAutomations && automationsConfig && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations button`}
variant="inverse"
disabled={
isAnyTeamSelected
? isFetchingTeamPolicies
: isFetchingGlobalPolicies
}
>
<span>Manage automations</span>
</Button>
<div className={`${baseClass}__manage-automations-wrapper`}>
<Dropdown
className={`${baseClass}__manage-automations-dropdown`}
onChange={onSelectAutomationOption}
placeholder="Manage automations"
searchable={false}
options={getAutomationsDropdownOptions()}
/>
</div>
)}
{canAddOrDeletePolicy && (
<div className={`${baseClass}__action-button-container`}>
@ -790,13 +913,13 @@ const ManagePolicyPage = ({
)}
</div>
)}
{config && automationsConfig && showManageAutomationsModal && (
{config && automationsConfig && showOtherWorkflowsModal && (
<OtherWorkflowsModal
automationsConfig={automationsConfig}
availableIntegrations={config.integrations}
availablePolicies={availablePoliciesForAutomation}
isUpdatingAutomations={isUpdatingAutomations}
onExit={toggleManageAutomationsModal}
onExit={toggleOtherWorkflowsModal}
handleSubmit={handleUpdateAutomations}
/>
)}
@ -815,6 +938,22 @@ const ManagePolicyPage = ({
onSubmit={onDeletePolicySubmit}
/>
)}
{showCalendarEventsModal && (
<CalendarEventsModal
onExit={toggleCalendarEventsModal}
updatePolicyEnabledCalendarEvents={
updatePolicyEnabledCalendarEvents
}
configured={isCalEventsConfigured}
enabled={
teamConfig?.integrations.google_calendar
?.enable_calendar_events ?? false
}
url={teamConfig?.integrations.google_calendar?.webhook_url || ""}
policies={teamPolicies || []}
isUpdating={updatingPolicyEnabledCalendarEvents}
/>
)}
</div>
</MainContent>
);

View file

@ -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 {

View file

@ -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<ICalendarEventsFormData>({
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<Record<string, string | null>>(
{}
);
const [showPreviewCalendarEvent, setShowPreviewCalendarEvent] = useState(
false
);
const [showExamplePayload, setShowExamplePayload] = useState(false);
const validateCalendarEventsFormData = (
curFormData: ICalendarEventsFormData
) => {
const errors: Record<string, string> = {};
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 (
<>
<pre>POST https://server.com/example</pre>
<pre
dangerouslySetInnerHTML={{
__html: syntaxHighlight({
timestamp: "0000-00-00T00:00:00Z",
host_id: 1,
host_display_name: "Anna's MacBook Pro",
host_serial_number: "ABCD1234567890",
failing_policies: [
{
id: 123,
name: "macOS - Disable guest account",
},
],
}),
}}
/>
</>
);
};
const renderPolicies = () => {
return (
<div className="form-field">
<div className="form-field__label">Policies:</div>
{formData.policies.map((policy) => {
const { isChecked, name, id } = policy;
return (
<div key={id}>
<Checkbox
value={isChecked}
name={name}
// can't use parseTarget as value needs to be set to !currentValue
onChange={() => {
onInputChange({ name, value: !isChecked });
}}
>
{name}
</Checkbox>
</div>
);
})}
<span className="form-field__help-text">
A calendar event will be created for end users if one of their hosts
fail any of these policies.{" "}
<CustomLink
url="https://www.fleetdm.com/learn-more-about/calendar-events"
text="Learn more"
newTab
/>
</span>
</div>
);
};
const renderPreviewCalendarEventModal = () => {
return (
<Modal
title="Calendar event preview"
width="large"
onExit={togglePreviewCalendarEvent}
className="calendar-event-preview"
>
<>
<p>A similar event will appear in the end user&apos;s calendar:</p>
<Graphic name="calendar-event-preview" />
<div className="modal-cta-wrap">
<Button onClick={togglePreviewCalendarEvent} variant="brand">
Done
</Button>
</div>
</>
</Modal>
);
};
const renderPlaceholderModal = () => {
return (
<div className="placeholder">
<a href="https://www.fleetdm.com/learn-more-about/calendar-events">
<Graphic name="calendar-event-preview" />
</a>
<div>
To create calendar events for end users if their hosts fail policies,
you must first connect Fleet to your Google Workspace service account.
</div>
<div>
This can be configured in{" "}
<b>Settings &gt; Integrations &gt; Calendars.</b>
</div>
<CustomLink
url="https://www.fleetdm.com/learn-more-about/calendar-events"
text="Learn more"
newTab
/>
<div className="modal-cta-wrap">
<Button onClick={onExit} variant="brand">
Done
</Button>
</div>
</div>
);
};
const renderConfiguredModal = () => (
<div className={`${baseClass} form`}>
<div className="form-header">
<Slider
value={formData.enabled}
onChange={() => {
onInputChange({ name: "enabled", value: !formData.enabled });
}}
inactiveText="Disabled"
activeText="Enabled"
/>
<Button
type="button"
variant="text-link"
onClick={togglePreviewCalendarEvent}
>
Preview calendar event
</Button>
</div>
<div
className={`form ${formData.enabled ? "" : "form-fields--disabled"}`}
>
<InputField
placeholder="https://server.com/example"
label="Resolution webhook URL"
onChange={onInputChange}
name="url"
value={formData.url}
parseTarget
error={formErrors.url}
tooltip="Provide a URL to deliver a webhook request to."
labelTooltipPosition="top-start"
helpText="A request will be sent to this URL during the calendar event. Use it to trigger auto-remidiation."
/>
<RevealButton
isShowing={showExamplePayload}
className={`${baseClass}__show-example-payload-toggle`}
hideText="Hide example payload"
showText="Show example payload"
caretPosition="after"
onClick={() => {
setShowExamplePayload(!showExamplePayload);
}}
/>
{showExamplePayload && renderExamplePayload()}
{renderPolicies()}
</div>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
onClick={() => {
updatePolicyEnabledCalendarEvents(formData);
}}
className="save-loading"
isLoading={isUpdating}
disabled={Object.keys(formErrors).length > 0}
>
Save
</Button>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
</div>
</div>
);
if (showPreviewCalendarEvent) {
return renderPreviewCalendarEventModal();
}
return (
<Modal
title="Calendar events"
onExit={onExit}
onEnter={
configured
? () => {
updatePolicyEnabledCalendarEvents(formData);
}
: onExit
}
className={baseClass}
width="large"
>
{configured ? renderConfiguredModal() : renderPlaceholderModal()}
</Modal>
);
};
export default CalendarEventsModal;

View file

@ -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;
}
}

View file

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

View file

@ -284,16 +284,6 @@ const generateTableHeaders = (
];
if (tableType !== "inheritedPolicies") {
tableHeaders.push({
title: "Automations",
Header: "Automations",
disableSortBy: true,
accessor: "webhook",
Cell: (cellProps: ICellProps): JSX.Element => (
<StatusIndicator value={cellProps.cell.value} />
),
});
if (!canAddOrDeletePolicy) {
return tableHeaders;
}

View file

@ -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[]) => {

View file

@ -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<ITeamWebhookSettings>;
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) {

View file

@ -227,3 +227,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);
}
}
}
}