diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 4b127cc1e4f..0689d73f883 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -120,6 +120,7 @@ export interface IPolicyFormData { id?: number; calendar_events_enabled?: boolean; conditional_access_enabled?: boolean; + continuous_automations_enabled?: boolean; software_title_id?: number | null; // null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script script_id?: number | null; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index a0e9d5259a3..962f3f5284a 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -962,6 +962,13 @@ const ManagePolicyPage = ({ {selectedPolicyForAutomations && ( refetchPolicies(teamIdForApi)} onExit={onCloseManageAutomationsModal} /> )} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/ManageAutomationsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/ManageAutomationsModal.tsx index ad8359b9f9a..b1de6b0989c 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/ManageAutomationsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/ManageAutomationsModal.tsx @@ -1,29 +1,450 @@ -import React from "react"; +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import React, { useContext, useMemo, useState } from "react"; +import { SingleValue } from "react-select-5"; + +import { NotificationContext } from "context/notification"; +import { IPolicyStats } from "interfaces/policy"; +import { IConfig } from "interfaces/config"; +import { ITeamConfig, API_NO_TEAM_ID } from "interfaces/team"; +import { PLATFORM_DISPLAY_NAMES, QueryablePlatform } from "interfaces/platform"; + import Modal from "components/Modal"; import Button from "components/buttons/Button"; -import { IPolicyStats } from "interfaces/policy"; +import Checkbox from "components/forms/fields/Checkbox"; +import CustomLink from "components/CustomLink"; +import DropdownWrapper, { + CustomOptionType, +} from "components/forms/fields/DropdownWrapper/DropdownWrapper"; +import Icon from "components/Icon"; +import TooltipWrapper from "components/TooltipWrapper"; + +import { getTicketOrWebhookLabel, getTicketOrWebhookState } from "./helpers"; +import { IAutomationRow } from "./types"; +import { + useScripts, + useSoftwareTitles, + useUpdatePolicyAutomations, +} from "./hooks"; const baseClass = "manage-automations-modal"; +const PLATFORM_DISPLAY_ORDER: QueryablePlatform[] = [ + "darwin", + "windows", + "linux", + "chrome", +]; + +const SUCCESS_MSG = "Successfully updated policy automations."; +const ERR_MSG = "Could not update policy automations."; + interface IManageAutomationsModalProps { policy: IPolicyStats; + fleetName: string; + isGlobalPolicy: boolean; + /** undefined for "All fleets", 0 for "Unassigned", positive for a fleet. */ + teamIdForApi: number | undefined; + automationsConfig: IConfig | ITeamConfig | undefined; + globalConfig: IConfig | undefined; + /** Policy IDs that have webhook/ticket (a.k.a. "other workflow") + * automations configured on this fleet. */ + webhookOrTicketPolicyIds: number[]; + refetchPolicies: () => void; onExit: () => void; } -// Placeholder – full implementation in follow-up PR const ManageAutomationsModal = ({ policy, + fleetName, + isGlobalPolicy, + teamIdForApi, + automationsConfig, + globalConfig, + webhookOrTicketPolicyIds, + refetchPolicies, onExit, }: IManageAutomationsModalProps): JSX.Element => { + const { renderFlash } = useContext(NotificationContext); + + const ticketOrWebhookState = getTicketOrWebhookState(automationsConfig); + + const isCalendarEnabledForTeam = !isGlobalPolicy + ? (automationsConfig as ITeamConfig | undefined)?.integrations + ?.google_calendar?.enable_calendar_events ?? false + : false; + + const getIsConditionalAccessEnabledForTeam = () => { + if (isGlobalPolicy) return false; + if (teamIdForApi === API_NO_TEAM_ID) { + return globalConfig?.integrations?.conditional_access_enabled ?? false; + } + return ( + (automationsConfig as ITeamConfig | undefined)?.integrations + ?.conditional_access_enabled ?? false + ); + }; + const isConditionalAccessEnabledForTeam = getIsConditionalAccessEnabledForTeam(); + + const initialWebhookOrTicket = webhookOrTicketPolicyIds.includes(policy.id); + const initialInstallSoftware = !!policy.install_software; + const initialRunScript = !!policy.run_script; + const initialCalendar = policy.calendar_events_enabled; + const initialConditionalAccess = policy.conditional_access_enabled; + const initialContinuous = policy.continuous_automations_enabled ?? false; + + const [webhookOrTicketEnabled, setWebhookOrTicketEnabled] = useState( + initialWebhookOrTicket + ); + const [installSoftware, setInstallSoftware] = useState( + initialInstallSoftware + ); + const [runScript, setRunScript] = useState(initialRunScript); + const [calendarEvent, setCalendarEvent] = useState(initialCalendar); + const [conditionalAccess, setConditionalAccess] = useState( + initialConditionalAccess + ); + const [continuousEnabled, setContinuousEnabled] = useState(initialContinuous); + + const [softwareTitleId, setSoftwareTitleId] = useState( + policy.install_software?.software_title_id ?? null + ); + const [scriptId, setScriptId] = useState( + policy.run_script?.id ?? null + ); + + const canFetchTeamScopedLists = !isGlobalPolicy && teamIdForApi !== undefined; + const { data: softwareTitlesData } = useSoftwareTitles({ + fleetId: teamIdForApi ?? 0, + enabled: canFetchTeamScopedLists && installSoftware, + }); + const { data: scriptsData } = useScripts({ + fleetId: teamIdForApi ?? 0, + enabled: canFetchTeamScopedLists && runScript, + }); + + const softwareOptions: CustomOptionType[] = useMemo( + () => + (softwareTitlesData?.software_titles ?? []).map((t) => ({ + label: t.name, + value: String(t.id), + })), + [softwareTitlesData] + ); + + const scriptOptions: CustomOptionType[] = useMemo( + () => + (scriptsData?.scripts ?? []).map((s) => ({ + label: s.name, + value: String(s.id), + })), + [scriptsData] + ); + + const policyPlatforms = (policy.platform ?? "") + .split(",") + .map((p) => p.trim()) + .filter((p): p is QueryablePlatform => + (PLATFORM_DISPLAY_ORDER as string[]).includes(p) + ); + const displayedPlatforms = PLATFORM_DISPLAY_ORDER.filter((p) => + policyPlatforms.includes(p) + ); + + const isTicketWebhookEnabled = ticketOrWebhookState !== "disabled"; + const rows: IAutomationRow[] = [ + { + key: "ticket_webhook", + label: getTicketOrWebhookLabel(ticketOrWebhookState), + checked: webhookOrTicketEnabled && isTicketWebhookEnabled, + onToggle: setWebhookOrTicketEnabled, + isDisabled: !isTicketWebhookEnabled, + }, + ]; + if (!isGlobalPolicy) { + rows.push( + { + key: "install_software", + label: "Install software", + tooltip: ( + + ), + checked: installSoftware, + onToggle: setInstallSoftware, + isDisabled: false, + picker: installSoftware ? ( + o.value === String(softwareTitleId ?? "") + ) ?? null + } + options={softwareOptions} + placeholder="Select software" + onChange={(opt: SingleValue) => + setSoftwareTitleId(opt ? Number(opt.value) : null) + } + /> + ) : undefined, + }, + { + key: "run_script", + label: "Run script", + tooltip: ( + + ), + checked: runScript, + onToggle: setRunScript, + isDisabled: false, + picker: runScript ? ( + o.value === String(scriptId ?? "")) ?? + null + } + options={scriptOptions} + placeholder="Select script" + onChange={(opt: SingleValue) => + setScriptId(opt ? Number(opt.value) : null) + } + /> + ) : undefined, + }, + { + key: "calendar_event", + label: "Calendar event", + tooltip: ( + + ), + checked: calendarEvent && isCalendarEnabledForTeam, + onToggle: setCalendarEvent, + isDisabled: !isCalendarEnabledForTeam, + }, + { + key: "conditional_access", + label: "Conditional access", + tooltip: ( + + ), + checked: conditionalAccess && isConditionalAccessEnabledForTeam, + onToggle: setConditionalAccess, + isDisabled: !isConditionalAccessEnabledForTeam, + } + ); + } + + const { mutate: save, isLoading: isSaving } = useUpdatePolicyAutomations({ + policy, + teamIdForApi, + isGlobalPolicy, + automationsConfig, + onSuccess: () => { + renderFlash("success", SUCCESS_MSG); + refetchPolicies(); + onExit(); + }, + onError: () => renderFlash("error", ERR_MSG), + }); + + const onSave = () => { + // Block enabling install/run without a selection — saving would silently + // unset the automation. + if (installSoftware && softwareTitleId === null) { + renderFlash("error", "Please select software to install."); + return; + } + if (runScript && scriptId === null) { + renderFlash("error", "Please select a script to run."); + return; + } + + const perPolicyDirty = + !isGlobalPolicy && + (installSoftware !== initialInstallSoftware || + softwareTitleId !== + (policy.install_software?.software_title_id ?? null) || + runScript !== initialRunScript || + scriptId !== (policy.run_script?.id ?? null) || + calendarEvent !== initialCalendar || + conditionalAccess !== initialConditionalAccess || + continuousEnabled !== initialContinuous); + const webhookDirty = webhookOrTicketEnabled !== initialWebhookOrTicket; + + if (!perPolicyDirty && !webhookDirty) { + onExit(); + return; + } + + save({ + policyUpdate: perPolicyDirty + ? { + software_title_id: installSoftware ? softwareTitleId : null, + script_id: runScript ? scriptId : null, + // When the feature disabled, the row is locked and the + // user can't toggle it — so we omit the field instead of carrying + // the stale state through to the PATCH. That preserves the policy's + // stored intent for if/when the fleet admin re-enables the feature. + ...(isCalendarEnabledForTeam && { + calendar_events_enabled: calendarEvent, + }), + ...(isConditionalAccessEnabledForTeam && { + conditional_access_enabled: conditionalAccess, + }), + continuous_automations_enabled: continuousEnabled, + } + : undefined, + webhookOrTicketUpdate: webhookDirty + ? { enabled: webhookOrTicketEnabled } + : undefined, + }); + }; + return ( - + +
+
+ Manage automations for the {policy.name} policy on{" "} + {fleetName}. +
+ + {displayedPlatforms.length > 0 && ( +
+

Platforms

+
+ {displayedPlatforms.map((p) => ( + + + {PLATFORM_DISPLAY_NAMES[p]} + + ))} +
+
+ )} + +
+

Automations

+ + + {rows.map((row) => ( + + + + + ))} + +
+ + {row.tooltip ? ( + + {row.label} + + ) : ( + row.label + )} + + + {row.isDisabled ? ( + + Not enabled for {fleetName} + + ) : ( + row.picker + )} +
+
+ {" "} + about automation types and their supported platforms. +
+
+ + {!isGlobalPolicy && ( +
+ + + Continuous + {" "} + software & script automations + +
+ )} +
+
- +
); }; +interface IAutomationRowTooltipProps { + text: string; + learnMoreUrl: string; +} + +function AutomationRowTooltip({ + text, + learnMoreUrl, +}: IAutomationRowTooltipProps): JSX.Element { + return ( + <> + {text}{" "} + + + ); +} + export default ManageAutomationsModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss new file mode 100644 index 00000000000..724277f90b0 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/_styles.scss @@ -0,0 +1,78 @@ +.manage-automations-modal { + &__body { + display: flex; + flex-direction: column; + gap: $pad-large; + } + + &__header { + color: $core-fleet-black; + } + + &__section { + display: flex; + flex-direction: column; + gap: $pad-small; + } + + &__section-title { + font-size: $small; + margin: 0; + } + + &__platforms { + display: flex; + flex-wrap: wrap; + gap: $pad-large; + } + + &__platform { + display: inline-flex; + align-items: center; + gap: $pad-xsmall; + } + + &__automations-table { + border-collapse: separate; + border-spacing: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: 8px; + width: 100%; + font-size: $x-small; + } + + &__row td { + height: 40px; + padding: $pad-xsmall $pad-large; + border-top: 1px solid $ui-fleet-black-10; + vertical-align: middle; + } + + &__row:first-child td { + border-top: none; + } + + &__row--disabled &__row-label { + color: $ui-fleet-black-50; + } + + &__row-trailing { + text-align: right; + width: 50%; + } + + &__row-disabled-hint { + color: $ui-fleet-black-50; + } + + &__row-picker { + max-width: 300px; + margin-left: auto; + text-align: left; + } + + &__learn-more { + font-size: $x-small; + color: $ui-fleet-black-75; + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/helpers.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/helpers.ts new file mode 100644 index 00000000000..1bcf98ecd01 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/helpers.ts @@ -0,0 +1,32 @@ +import { IConfig } from "interfaces/config"; +import { ITeamConfig } from "interfaces/team"; +import { TicketOrWebhookState } from "./types"; + +/** Identifies whether the team has webhook automations or ticket integrations + * enabled for failing policies. */ +export const getTicketOrWebhookState = ( + automationsConfig: IConfig | ITeamConfig | undefined +): TicketOrWebhookState => { + if (!automationsConfig) return "disabled"; + + const webhookEnabled = + automationsConfig.webhook_settings?.failing_policies_webhook + ?.enable_failing_policies_webhook ?? false; + + const integrations = automationsConfig.integrations; + const ticketEnabled = + !!integrations?.jira?.some((j) => j.enable_failing_policies) || + !!integrations?.zendesk?.some((z) => z.enable_failing_policies); + + if (webhookEnabled) return "webhook"; + if (ticketEnabled) return "ticket"; + return "disabled"; +}; + +export const getTicketOrWebhookLabel = ( + state: TicketOrWebhookState +): string => { + if (state === "webhook") return "Send webhook"; + if (state === "ticket") return "Create ticket"; + return "Send webhook or create ticket"; +}; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/index.ts new file mode 100644 index 00000000000..755e41cba01 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/index.ts @@ -0,0 +1,3 @@ +export { default as useScripts } from "./useScripts"; +export { default as useSoftwareTitles } from "./useSoftwareTitles"; +export { default as useUpdatePolicyAutomations } from "./useUpdatePolicyAutomations"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useScripts.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useScripts.ts new file mode 100644 index 00000000000..ddbdca2acb7 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useScripts.ts @@ -0,0 +1,30 @@ +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import scriptsAPI, { + IListScriptsQueryKey, + IScriptsResponse, +} from "services/entities/scripts"; + +const SCRIPTS_PAGE_SIZE = 1000; + +interface IUseScriptsArgs { + fleetId: number; + enabled: boolean; +} + +const useScripts = ({ fleetId, enabled }: IUseScriptsArgs) => + useQuery( + [ + { + scope: "scripts", + page: 0, + per_page: SCRIPTS_PAGE_SIZE, + fleet_id: fleetId, + }, + ], + ({ queryKey: [key] }) => scriptsAPI.getScripts(omit(key, "scope")), + { enabled, staleTime: 30_000 } + ); + +export default useScripts; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useSoftwareTitles.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useSoftwareTitles.ts new file mode 100644 index 00000000000..c12b85df3e1 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useSoftwareTitles.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import { CommaSeparatedPlatformString } from "interfaces/platform"; +import softwareAPI, { + ISoftwareTitlesQueryKey, + ISoftwareTitlesResponse, +} from "services/entities/software"; + +const SOFTWARE_PAGE_SIZE = 1000; + +interface IUseSoftwareTitlesArgs { + fleetId: number; + enabled: boolean; +} + +const useSoftwareTitles = ({ fleetId, enabled }: IUseSoftwareTitlesArgs) => + useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitlesResponse, + [ISoftwareTitlesQueryKey] + >( + [ + { + scope: "software-titles", + page: 0, + perPage: SOFTWARE_PAGE_SIZE, + query: "", + orderDirection: "desc", + orderKey: "hosts_count", + teamId: fleetId, + availableForInstall: true, + platform: "darwin,windows,linux" as CommaSeparatedPlatformString, + }, + ], + ({ queryKey: [key] }) => softwareAPI.getSoftwareTitles(omit(key, "scope")), + { enabled, staleTime: 30_000 } + ); + +export default useSoftwareTitles; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useUpdatePolicyAutomations.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useUpdatePolicyAutomations.ts new file mode 100644 index 00000000000..56f5cc5efec --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/hooks/useUpdatePolicyAutomations.ts @@ -0,0 +1,104 @@ +import { useContext } from "react"; +import { useMutation, useQueryClient } from "react-query"; + +import { AppContext } from "context/app"; +import { IConfig } from "interfaces/config"; +import { IPolicyFormData, IPolicyStats } from "interfaces/policy"; +import { ITeamConfig } from "interfaces/team"; +import configAPI from "services/entities/config"; +import teamPoliciesAPI from "services/entities/team_policies"; +import teamsAPI from "services/entities/teams"; + +/** The per-policy automation fields settable from the modal. */ +export type IPolicyAutomationUpdate = Pick< + IPolicyFormData, + | "software_title_id" + | "script_id" + | "calendar_events_enabled" + | "conditional_access_enabled" + | "continuous_automations_enabled" +>; + +export interface IUpdatePolicyAutomationsVars { + /** Present only when per-policy automation fields changed. */ + policyUpdate?: IPolicyAutomationUpdate; + /** Present only when webhook/ticket membership changed; `enabled` is the + * desired membership for this policy. */ + webhookOrTicketUpdate?: { enabled: boolean }; +} + +interface IUseUpdatePolicyAutomationsArgs { + policy: IPolicyStats; + teamIdForApi: number | undefined; + isGlobalPolicy: boolean; + automationsConfig: IConfig | ITeamConfig | undefined; + onSuccess?: () => void; + onError?: () => void; +} + +/** Saves a single policy's automations: the per-policy fields via the policy + * update endpoint, and webhook/ticket membership via the fleet/global config. */ +const useUpdatePolicyAutomations = ({ + policy, + teamIdForApi, + isGlobalPolicy, + automationsConfig, + onSuccess, + onError, +}: IUseUpdatePolicyAutomationsArgs) => { + const queryClient = useQueryClient(); + const { setConfig } = useContext(AppContext); + + if (!isGlobalPolicy && teamIdForApi === undefined) { + throw new Error("Missing fleet id for team-scoped policy automations."); + } + + // Adds or removes this policy from the fleet/global webhook+ticket policy_ids + // list (the backend stores membership for both webhooks and tickets there). + const saveWebhookOrTicketMembership = async (enabled: boolean) => { + const existingWebhook = + automationsConfig?.webhook_settings?.failing_policies_webhook ?? {}; + const currentIds = existingWebhook.policy_ids ?? []; + const nextIds = enabled + ? Array.from(new Set([...currentIds, policy.id])) + : currentIds.filter((id) => id !== policy.id); + + const payload = { + webhook_settings: { + failing_policies_webhook: { ...existingWebhook, policy_ids: nextIds }, + }, + }; + + if (isGlobalPolicy) { + const updatedConfig = await configAPI.update(payload); + queryClient.setQueryData(["config"], updatedConfig); + setConfig(updatedConfig); + } else { + const updatedTeam = await teamsAPI.update(payload, teamIdForApi); + queryClient.setQueryData(["teams", teamIdForApi], updatedTeam); + } + }; + + return useMutation( + ({ policyUpdate, webhookOrTicketUpdate }: IUpdatePolicyAutomationsVars) => { + const requests: Promise[] = []; + if (policyUpdate) { + requests.push( + teamPoliciesAPI.update(policy.id, { + team_id: teamIdForApi, + ...policyUpdate, + }) + ); + } + if (webhookOrTicketUpdate) { + requests.push( + saveWebhookOrTicketMembership(webhookOrTicketUpdate.enabled) + ); + } + return Promise.all(requests); + }, + { onSuccess, onError } + ); +}; + +export default useUpdatePolicyAutomations; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/types.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/types.ts new file mode 100644 index 00000000000..19f0ffe4a4f --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ManageAutomationsModal/types.ts @@ -0,0 +1,18 @@ +export type TicketOrWebhookState = "webhook" | "ticket" | "disabled"; + +type IAutomationRowKey = + | "ticket_webhook" + | "install_software" + | "run_script" + | "calendar_event" + | "conditional_access"; + +export interface IAutomationRow { + key: IAutomationRowKey; + label: string; + tooltip?: React.ReactNode; + checked: boolean; + onToggle: (next: boolean) => void; + isDisabled: boolean; + picker?: React.ReactNode; +} diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index a7cfdeecaf2..f51b218f539 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -116,6 +116,7 @@ export default { // automations-related fields calendar_events_enabled, conditional_access_enabled, + continuous_automations_enabled, software_title_id, script_id, labels_include_any, @@ -134,6 +135,7 @@ export default { critical, calendar_events_enabled, conditional_access_enabled, + continuous_automations_enabled, software_title_id, script_id, labels_include_any,