mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
4db06f2cbb
commit
5137fe380c
23 changed files with 1917 additions and 145 deletions
|
|
@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
|||
integrations: {
|
||||
jira: [],
|
||||
zendesk: [],
|
||||
google_calendar: [],
|
||||
},
|
||||
logging: {
|
||||
debug: false,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal file
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { QueryContext } from "context/query";
|
|||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import activitiesAPI, {
|
||||
IActivitiesResponse,
|
||||
IPastActivitiesResponse,
|
||||
IUpcomingActivitiesResponse,
|
||||
} from "services/entities/activities";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'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 > Integrations > 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CalendarEventsModal";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue