diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 3578993d72..4e6760faa1 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -395,6 +395,8 @@ const DEFAULT_FLEET_MAINTAINED_APP_DETAILS_MOCK: IFleetMaintainedAppDetails = { post_install_script: 'echo "Installed"', uninstall_script: "#!/bin/sh\n\n# Fleet extracts and saves package IDs\npkg_ids=$PACKAGE_ID", + automatic_install_query: + "SELECT 1 FROM apps WHERE bundle_identifier = 'com.example.test-app';", slug: "applications/test-app", url: "http://www.testurl1234abcd.com/testapp", categories: ["Browsers"], diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx index 8f3f663aea..da24019e54 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx @@ -10,6 +10,7 @@ import { getSoftwareInstallHandlerWithHash, getSoftwareInstallHandlerWithPreInstall, getSoftwareInstallHandlerOnlyPreInstallOutput, + getSoftwareInstallHandlerAppOpen, getSoftwareInstallResultHandlerPremiumRequired, } from "test/handlers/software-handlers"; import mockServer from "test/mock-server"; @@ -135,6 +136,28 @@ describe("SoftwareInstallDetailsModal", () => { expect(screen.getByText(/\d+.*ago/)).toBeInTheDocument(); }); + it("renders app-open skipped copy instead of generic failed-install copy", () => { + render( + + ); + + expect(screen.getByText(/Fleet skipped install of/)).toBeInTheDocument(); + expect(screen.getByText(/The app was open/)).toBeInTheDocument(); + expect( + screen.getByText( + /It will update once the user closes it and policy runs again, or update via self service\./ + ) + ).toBeInTheDocument(); + expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument(); + }); + it("on host details page/install activity, renders installed message with timestamp", () => { render( { ).not.toBeInTheDocument(); }); + it("renders the app-open pre-install output for a skipped install", async () => { + mockServer.use(getSoftwareInstallHandlerAppOpen); + const renderWithServer = createCustomRenderer({ withBackendMock: true }); + const { user } = renderWithServer( + + ); + + await screen.findByText(/Fleet skipped install of/); + await user.click(screen.getByRole("button", { name: /Details/i })); + + expect( + screen.getByText("Query didn't return result or failed:") + ).toBeInTheDocument(); + expect(screen.getByText("The app was open")).toBeInTheDocument(); + expect(screen.queryByText("Install stopped")).not.toBeInTheDocument(); + }); + it("shows install and post-install outputs after clicking Details (no pre-install)", async () => { mockServer.use(getDefaultSoftwareInstallHandler); const renderWithServer = createCustomRenderer({ withBackendMock: true }); diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx index 3845bb801e..d33cca3f09 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx @@ -49,6 +49,7 @@ const baseClass = "software-install-details-modal"; export type IPackageInstallDetails = { host_display_name?: string; install_uuid?: string; // not actually optional + install_skipped_when_app_open?: boolean; }; export const renderContactOption = (url?: string) => ( @@ -73,6 +74,7 @@ interface IInstallStatusMessage { - From Activity feed: never override (always show the failure). Parity with VPPInstallDetailsModal/SoftwareIpaInstallDetailsModal */ canOverrideFailureWithInstalled?: boolean; + installSkippedWhenAppOpen?: boolean; } // TODO - match VppInstallDetailsModal status to this, still accounting for MDM-specific cases @@ -83,6 +85,7 @@ export const StatusMessage = ({ isMyDevicePage, contactUrl, canOverrideFailureWithInstalled = false, + installSkippedWhenAppOpen = false, }: IInstallStatusMessage) => { // the case when software is installed by the user and not by Fleet if (!installResult) { @@ -143,6 +146,23 @@ export const StatusMessage = ({ })})` : ""; + if (installSkippedWhenAppOpen && status === "failed_install") { + return ( + + Fleet skipped install of {software_title} ({software_package} + ) on {formattedHost} + {displayTimeStamp}. The app was open. It will update once the user + closes it and policy runs again, or update via self service. + + } + /> + ); + } + const renderStatusCopy = () => { const prefix = ( <> @@ -295,8 +315,12 @@ export const SoftwareInstallDetailsModal = ({ const renderInstallDetailsSection = () => { const outputs = [ { - label: "Pre-install query output:", - value: swInstallResult?.pre_install_query_output, + label: detailsFromProps.install_skipped_when_app_open + ? "Query didn't return result or failed:" + : "Pre-install query output:", + value: detailsFromProps.install_skipped_when_app_open + ? "The app was open" + : swInstallResult?.pre_install_query_output, }, { label: "Install script output:", @@ -312,7 +336,8 @@ export const SoftwareInstallDetailsModal = ({ const showDetailsButton = (!!swInstallResult?.post_install_script_output || !!swInstallResult?.output || - !!swInstallResult?.pre_install_query_output) && + !!swInstallResult?.pre_install_query_output || + !!detailsFromProps.install_skipped_when_app_open) && swInstallResult?.status !== "pending_install"; return ( @@ -453,6 +478,9 @@ export const SoftwareInstallDetailsModal = ({ isMyDevicePage={!!deviceAuthToken} contactUrl={contactUrl} canOverrideFailureWithInstalled={canOverrideFailureWithInstalled} + installSkippedWhenAppOpen={ + detailsFromProps.install_skipped_when_app_open + } /> {/* Package SHA-256 hash — backend hydrates `hash_sha256` on the diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts index c3b9ebffda..4946e698ef 100644 --- a/frontend/components/ActivityDetails/InstallDetails/constants.ts +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -1,6 +1,6 @@ import { IconNames } from "components/icons"; import { - SoftwareInstallUninstallStatus, + SoftwareInstallDetailsStatus, EnhancedSoftwareInstallUninstallStatus, SoftwareInstallStatus, } from "interfaces/software"; @@ -8,7 +8,7 @@ import { // Install/Uninstall helpers export const INSTALL_DETAILS_STATUS_ICONS: Record< - SoftwareInstallUninstallStatus, // former is superset of latter, latter included in union for type system + SoftwareInstallDetailsStatus, IconNames > = { pending_install: "pending-outline", @@ -17,10 +17,11 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record< failed_install: "error", pending_uninstall: "pending-outline", failed_uninstall: "error", + skipped_install: "info", } as const; const INSTALL_DETAILS_STATUS_PREDICATES: Record< - EnhancedSoftwareInstallUninstallStatus, + EnhancedSoftwareInstallUninstallStatus | "skipped_install", string > = { pending_install: "is installing or will install", @@ -32,6 +33,7 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record< pending_script: "is running or will run", failed_script: "failed to run", ran_script: "ran", + skipped_install: "skipped install of", } as const; export const getInstallDetailsStatusPredicate = ( diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 30981d4321..376602a735 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -305,6 +305,7 @@ export interface IActivityDetails { host_platform?: string; host_serial?: string; install_uuid?: string; + install_skipped_when_app_open?: boolean; installed_from_dep?: boolean; labels_exclude_any?: ILabelSoftwareTitle[]; labels_include_any?: ILabelSoftwareTitle[]; diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index ab482f933c..930b2199bf 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -72,6 +72,7 @@ export interface IPolicy { run_script?: Pick; patch_software?: IPolicySoftwareToInstall; continuous_automations_enabled?: boolean; + patch_when_closed?: boolean; labels_include_any?: ILabelPolicy[]; labels_include_all?: ILabelPolicy[]; labels_exclude_any?: ILabelPolicy[]; @@ -146,6 +147,7 @@ export interface IPolicyFormData { calendar_events_enabled?: boolean; conditional_access_enabled?: boolean; continuous_automations_enabled?: boolean; + patch_when_closed?: boolean; software_title_id?: number | null; /** Pins the policy to a specific package on a multi-package title. `null` * on PATCH lets the backend fall back to the title's first-added package diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index e213c86430..7d01bf9f72 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -69,6 +69,8 @@ export interface ISoftwareTitleVersion { export interface ISoftwarePatchPolicy { id: number; name: string; + patch_when_closed: boolean; + continuous_automations_enabled?: boolean; } export type SoftwareInstallPolicyType = "dynamic" | "patch"; @@ -472,6 +474,12 @@ export const SOFTWARE_INSTALL_UNINSTALL_STATUSES = [ */ export type SoftwareInstallUninstallStatus = typeof SOFTWARE_INSTALL_UNINSTALL_STATUSES[number]; +/** Activity-backed install details can display a skipped state while the + * persisted install result remains failed_install. */ +export type SoftwareInstallDetailsStatus = + | SoftwareInstallUninstallStatus + | "skipped_install"; + /** Include script-only software statuses */ export const ENAHNCED_SOFTWARE_INSTALL_UNINSTALL_STATUSES = [ ...SOFTWARE_INSTALL_STATUSES, @@ -945,6 +953,7 @@ export interface IFleetMaintainedAppDetails { install_script: string; post_install_script: string; uninstall_script: string; + automatic_install_query: string; url: string; slug: string; software_title_id?: number; // null unless the team already has the software added (as a Fleet-maintained app, App Store (app), or custom package) diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index d87a85ded4..78c36bb55e 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -1917,6 +1917,47 @@ describe("Activity Feed", () => { expect(screen.getByText("Script-only Software")).toBeInTheDocument(); }); + it("renders skipped copy when the app was open", () => { + const activity = createMockActivity({ + type: ActivityType.InstalledSoftware, + actor_full_name: "Fleet", + fleet_initiated: true, + details: { + software_title: "Firefox", + software_package: "Firefox.pkg", + host_display_name: "Work Mac", + source: "apps", + status: "failed_install", + install_skipped_when_app_open: true, + }, + }); + + render(); + expect(screen.getByText(/skipped install of/)).toBeInTheDocument(); + expect(screen.getByText("Firefox")).toBeInTheDocument(); + expect(screen.getByText("Work Mac")).toBeInTheDocument(); + expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument(); + }); + + it("keeps generic failed-install copy when the app-open flag is absent", () => { + const activity = createMockActivity({ + type: ActivityType.InstalledSoftware, + actor_full_name: "Fleet", + fleet_initiated: true, + details: { + software_title: "Firefox", + software_package: "Firefox.pkg", + host_display_name: "Work Mac", + source: "apps", + status: "failed_install", + }, + }); + + render(); + expect(screen.getByText(/failed to install/)).toBeInTheDocument(); + expect(screen.queryByText(/skipped install/)).not.toBeInTheDocument(); + }); + it("renders py script package ran status in InstalledSoftware activity", () => { const activity = createMockActivity({ type: ActivityType.InstalledSoftware, diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index df0d50b147..594f5618b2 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1478,6 +1478,7 @@ const TAGGED_TEMPLATES = { source, self_service, from_setup_experience, + install_skipped_when_app_open, } = details; const showSoftwarePackage = @@ -1485,6 +1486,15 @@ const TAGGED_TEMPLATES = { activity.type === ActivityType.InstalledSoftware; const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || ""); + if (install_skipped_when_app_open) { + return ( + <> + {" "} + skipped install of {title} on {hostName}. + + ); + } + // Self-service actions: drop the actor and switch to passive voice so the // sentence reads " was installed on <host> (self-service)." without // misattributing the action. diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tests.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tests.tsx new file mode 100644 index 0000000000..58b1cebaaf --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tests.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; + +import FleetAppDetailsForm from "./FleetAppDetailsForm"; + +const defaultProps: React.ComponentProps<typeof FleetAppDetailsForm> = { + categories: [], + defaultInstallScript: "install", + defaultPostInstallScript: "post-install", + defaultUninstallScript: "uninstall", + teamId: "1", + onCancel: jest.fn(), + onSubmit: jest.fn(), +}; + +const renderForm = (gitOpsModeEnabled = false) => { + const render = createCustomRenderer({ + context: { + app: { + config: { gitops: { gitops_mode_enabled: gitOpsModeEnabled } }, + }, + }, + }); + return render(<FleetAppDetailsForm {...defaultProps} />); +}; + +describe("FleetAppDetailsForm", () => { + beforeEach(() => jest.clearAllMocks()); + + it("submits Self-service and Deploy selections", async () => { + const { user } = renderForm(); + const selfServiceSwitch = screen + .getByText("Self-service") + .closest(".fleet-slider__wrapper") + ?.querySelector('[role="switch"]'); + expect(selfServiceSwitch).not.toBeNull(); + + await user.click(selfServiceSwitch as Element); + await user.click(screen.getByRole("checkbox", { name: "force-install" })); + await user.click(screen.getByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("radio", { name: "Force patch" })); + await user.click(screen.getByRole("button", { name: "Add software" })); + + expect(defaultProps.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + selfService: true, + forceInstall: true, + patch: true, + patchOption: "force", + }) + ); + }); + + it("disables Deploy and Add software in GitOps mode", () => { + renderForm(true); + + expect( + screen.getByRole("checkbox", { name: "force-install" }) + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("checkbox", { name: "patch" })).toHaveAttribute( + "aria-disabled", + "true" + ); + expect(screen.getByRole("button", { name: "Add software" })).toBeDisabled(); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx index 8fa28362ee..2af0e6d0c0 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx @@ -10,7 +10,11 @@ import Button from "components/buttons/Button"; import TooltipWrapper from "components/TooltipWrapper"; import CustomLink from "components/CustomLink"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; -import SoftwareDeploySlider from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; +import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector"; +import { + PatchOption, + SoftwareDeploySelector, +} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; const baseClass = "fleet-app-details-form"; @@ -40,7 +44,9 @@ export const softwareAlreadyAddedTipContent = ( }; export interface IFleetMaintainedAppFormData { selfService: boolean; - automaticInstall: boolean; + forceInstall: boolean; + patch: boolean; + patchOption: PatchOption; installScript: string; preInstallQuery?: string; postInstallScript?: string; @@ -80,7 +86,9 @@ const FleetAppDetailsForm = ({ }: IFleetAppDetailsFormProps) => { const [formData, setFormData] = useState<IFleetMaintainedAppFormData>({ selfService: false, - automaticInstall: false, + forceInstall: false, + patch: false, + patchOption: "closed", preInstallQuery: "", installScript: defaultInstallScript, postInstallScript: defaultPostInstallScript, @@ -91,10 +99,10 @@ const FleetAppDetailsForm = ({ categories: categories || [], }); - const onToggleDeploySoftware = () => { + const onToggleSelfService = () => { setFormData((prevData: IFleetMaintainedAppFormData) => ({ ...prevData, - automaticInstall: !prevData.automaticInstall, + selfService: !prevData.selfService, })); }; @@ -108,9 +116,31 @@ const FleetAppDetailsForm = ({ return ( <form className={baseClass} onSubmit={onSubmitForm}> - <SoftwareDeploySlider - deploySoftware={formData.automaticInstall} - onToggleDeploySoftware={onToggleDeploySoftware} + <SoftwareOptionsSelector + formData={formData} + onToggleSelfService={onToggleSelfService} + onClickPreviewEndUserExperience={() => undefined} + onSelectCategory={() => undefined} + /> + <GitOpsModeTooltipWrapper + entityType="software" + renderChildren={(disableChildren) => ( + <SoftwareDeploySelector + forceInstall={formData.forceInstall} + patch={formData.patch} + patchOption={formData.patchOption} + onToggleForceInstall={(forceInstall) => + setFormData((prevData) => ({ ...prevData, forceInstall })) + } + onTogglePatch={(patch) => + setFormData((prevData) => ({ ...prevData, patch })) + } + onSelectPatchOption={(patchOption) => + setFormData((prevData) => ({ ...prevData, patchOption })) + } + disabled={disableChildren} + /> + )} /> <div className={`${baseClass}__action-buttons`}> <GitOpsModeTooltipWrapper diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tests.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tests.tsx new file mode 100644 index 0000000000..b6fcd84982 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tests.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; + +import { createMockFleetMaintainedAppDetails } from "__mocks__/softwareMock"; +import softwareAPI from "services/entities/software"; +import teamPoliciesAPI from "services/entities/team_policies"; +import { createCustomRenderer, createMockRouter } from "test/test-utils"; + +import FleetMaintainedAppDetailsPage from "./FleetMaintainedAppDetailsPage"; + +describe("FleetMaintainedAppDetailsPage", () => { + beforeEach(() => { + jest.spyOn(softwareAPI, "getFleetMaintainedApp").mockResolvedValue({ + fleet_maintained_app: createMockFleetMaintainedAppDetails(), + }); + jest + .spyOn(softwareAPI, "addFleetMaintainedApp") + .mockResolvedValue({ software_title_id: 99 }); + jest.spyOn(teamPoliciesAPI, "create").mockResolvedValue({} as never); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("adds the FMA before creating its patch policy", async () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { app: { isPremiumTier: true } }, + }); + const router = createMockRouter(); + const { user } = render( + <FleetMaintainedAppDetailsPage + location={ + ({ query: { fleet_id: "3" } } as unknown) as React.ComponentProps< + typeof FleetMaintainedAppDetailsPage + >["location"] + } + router={router} + routeParams={{ id: "1" }} + /> + ); + + await user.click(await screen.findByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("button", { name: "Add software" })); + + await waitFor(() => { + expect(softwareAPI.addFleetMaintainedApp).toHaveBeenCalledWith( + 3, + expect.objectContaining({ patch: true, patchOption: "closed" }) + ); + expect(teamPoliciesAPI.create).toHaveBeenCalledWith({ + team_id: 3, + type: "patch", + patch_software_title_id: 99, + software_title_id: 99, + patch_when_closed: true, + continuous_automations_enabled: true, + }); + }); + + expect( + (softwareAPI.addFleetMaintainedApp as jest.Mock).mock + .invocationCallOrder[0] + ).toBeLessThan( + (teamPoliciesAPI.create as jest.Mock).mock.invocationCallOrder[0] + ); + }); + + it("navigates to the added software when patch policy creation fails", async () => { + (teamPoliciesAPI.create as jest.Mock).mockRejectedValueOnce( + new Error("Patch failed") + ); + const render = createCustomRenderer({ + withBackendMock: true, + context: { app: { isPremiumTier: true } }, + }); + const router = createMockRouter(); + const { user } = render( + <FleetMaintainedAppDetailsPage + location={ + ({ query: { fleet_id: "3" } } as unknown) as React.ComponentProps< + typeof FleetMaintainedAppDetailsPage + >["location"] + } + router={router} + routeParams={{ id: "1" }} + /> + ); + + await user.click(await screen.findByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("button", { name: "Add software" })); + + await waitFor(() => { + expect(softwareAPI.addFleetMaintainedApp).toHaveBeenCalledTimes(1); + expect(teamPoliciesAPI.create).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith( + expect.stringMatching(/\/software\/titles\/99.*fleet_id=3/) + ); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index fb65787c22..af3272c27b 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -9,6 +9,7 @@ import PATHS from "router/paths"; import { getPathWithQueryParams } from "utilities/url"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import softwareAPI from "services/entities/software"; +import teamPoliciesAPI from "services/entities/team_policies"; import { AppContext } from "context/app"; import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; @@ -24,6 +25,7 @@ import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import PageDescription from "components/PageDescription"; +import { getPatchPolicyFlags } from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; import FleetAppDetailsForm from "./FleetAppDetailsForm"; import { IFleetMaintainedAppFormData } from "./FleetAppDetailsForm/FleetAppDetailsForm"; @@ -177,13 +179,29 @@ const FleetMaintainedAppDetailsPage = ({ setShowAddFleetAppSoftwareModal(true); + let softwareFmaTitleId: number | undefined; try { - const { - software_title_id: softwareFmaTitleId, - } = await softwareAPI.addFleetMaintainedApp(parseInt(teamId, 10), { - ...formData, - appId, - }); + const response = await softwareAPI.addFleetMaintainedApp( + parseInt(teamId, 10), + { + ...formData, + appId, + } + ); + const addedSoftwareTitleId = response.software_title_id; + softwareFmaTitleId = addedSoftwareTitleId; + + if (formData.patch) { + await teamPoliciesAPI.create({ + team_id: parseInt(teamId, 10), + type: "patch", + patch_software_title_id: addedSoftwareTitleId, + ...(formData.patchOption !== "manual" && { + software_title_id: addedSoftwareTitleId, + }), + ...getPatchPolicyFlags(formData.patchOption), + }); + } queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], @@ -197,7 +215,7 @@ const FleetMaintainedAppDetailsPage = ({ router.push( getPathWithQueryParams( - PATHS.SOFTWARE_TITLE_DETAILS(softwareFmaTitleId.toString()), + PATHS.SOFTWARE_TITLE_DETAILS(addedSoftwareTitleId.toString()), { fleet_id: teamId, } @@ -212,7 +230,29 @@ const FleetMaintainedAppDetailsPage = ({ } catch (error) { const ae = (typeof error === "object" ? error : {}) as AxiosResponse; - notify.error(getErrorMessage(ae), { response: error }); + if (softwareFmaTitleId) { + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-titles" }], + }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "fleet-maintained-apps" }], + }); + router.push( + getPathWithQueryParams( + PATHS.SOFTWARE_TITLE_DETAILS(softwareFmaTitleId.toString()), + { fleet_id: teamId } + ) + ); + notify.error( + "Software was added, but the deployment settings couldn't be saved. Try again from Actions > Deploy.", + { response: error } + ); + } else { + notify.error(getErrorMessage(ae), { response: error }); + } } setShowAddFleetAppSoftwareModal(false); @@ -240,7 +280,7 @@ const FleetMaintainedAppDetailsPage = ({ className={`${baseClass}__back-to-add-software`} /> <h1>{fleetApp.name}</h1> - <PageDescription content="Add software to your library. You can add it to self-service later." /> + <PageDescription content="Add software to your library." /> <div className={`${baseClass}__page-content`}> <FleetAppSummary name={fleetApp.name} diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers.tsx index 825ac10497..881a763241 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers.tsx @@ -10,36 +10,6 @@ import { ensurePeriod, formatAlreadyAvailableInstallMessage, } from "../../helpers"; -import fleetAppData from "../../../../../../server/mdm/maintainedapps/apps.json"; - -const NameToIdentifierMap: Record<string, string> = { - "1Password": "1password", - "Adobe Acrobat Reader": "adobe-acrobat-reader", - "Box Drive": "box-drive", - Brave: "brave-browser", - "Cloudflare One": "cloudflare-warp", - "Docker Desktop": "docker", - Figma: "figma", - "Mozilla Firefox": "firefox", - "Google Chrome": "google-chrome", - "Microsoft Edge": "microsoft-edge", - "Microsoft Excel": "microsoft-excel", - "Microsoft Teams": "microsoft-teams", - "Microsoft Word": "microsoft-word", - Notion: "notion", - Postman: "postman", - Slack: "slack", - TeamViewer: "teamviewer", - "Microsoft Visual Studio Code": "visual-studio-code", - WhatsApp: "whatsapp", - Zoom: "zoom", - "Zoom for IT Admins": "zoom-for-it-admins", -}; - -const getFleetAppData = (name: string) => { - const appId = NameToIdentifierMap[name]; // TODO: need a better matching mechanism here - return fleetAppData.find((app) => app.identifier === appId); -}; export const getFleetAppPolicyName = (appName: string) => { return `[Install software] ${appName}`; @@ -49,10 +19,6 @@ export const getFleetAppPolicyDescription = (appName: string) => { return `Policy triggers automatic install of ${appName} on each host that's missing this software.`; }; -export const getFleetAppPolicyQuery = (name: string) => { - return getFleetAppData(name)?.automatic_policy_query; -}; - export const getErrorMessage = (err: unknown) => { const isTimeout = isAxiosError(err) && diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tests.tsx deleted file mode 100644 index 4775a44567..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tests.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { screen } from "@testing-library/react"; - -import { noop } from "lodash"; - -import { createCustomRenderer } from "test/test-utils"; -import AddPatchPolicyModal from "./AddPatchPolicyModal"; - -const renderModal = (props: { gitOpsModeEnabled?: boolean } = {}) => { - const customRender = createCustomRenderer({ - context: { - app: { - config: { - gitops: { - gitops_mode_enabled: props.gitOpsModeEnabled ?? false, - }, - }, - }, - }, - }); - - return customRender( - <AddPatchPolicyModal - softwareId={1} - teamId={1} - onExit={noop} - onSuccess={noop} - {...props} - /> - ); -}; - -describe("AddPatchPolicyModal", () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - it("renders add button as disabled when gitOpsModeEnabled is true", async () => { - const { user } = renderModal({ gitOpsModeEnabled: true }); - - const addButton = screen.getByRole("button", { name: "Add" }); - expect(addButton).toBeDisabled(); - await user.hover(addButton); - }); -}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx deleted file mode 100644 index 2fc2a14995..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useCallback, useState } from "react"; - -import teamPoliciesAPI from "services/entities/team_policies"; - -import { getErrorReason } from "interfaces/errors"; - -import { notify } from "components/ToastNotification"; -import Modal from "components/Modal"; -import Button from "components/buttons/Button"; -import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; - -const baseClass = "add-patch-policy-modal"; - -const EXISTING_PATCH_POLICY_ERROR_MSG = `Couldn't add patch policy. Specified "patch_software_title_id" already has a policy with "type" set to "patch".`; - -interface IAddPatchPolicyModal { - softwareId: number; - teamId: number; - onExit: () => void; - onSuccess: () => void; -} - -const AddPatchPolicyModal = ({ - softwareId, - teamId, - onExit, - onSuccess, -}: IAddPatchPolicyModal) => { - const [isAddingPatchPolicy, setIsAddingPatchPolicy] = useState(false); - - const onAddPatchPolicy = useCallback(async () => { - setIsAddingPatchPolicy(true); - try { - await teamPoliciesAPI.create({ - type: "patch", - patch_software_title_id: softwareId, - team_id: teamId, - }); - notify.success("Successfully added patch policy."); - onSuccess(); - } catch (error) { - const reason = getErrorReason(error); - if (reason.includes("already has a policy")) { - notify.error(EXISTING_PATCH_POLICY_ERROR_MSG, { response: error }); - } else { - notify.error("Couldn't add patch policy. Please try again.", { - response: error, - }); - } - } - setIsAddingPatchPolicy(false); - onExit(); - }, [softwareId, teamId, onSuccess, onExit]); - - return ( - <Modal - className={baseClass} - title="Add patch policy" - onExit={onExit} - isContentDisabled={isAddingPatchPolicy} - > - <> - <p> - This creates a read-only policy. Later, to enforce remediation, head - to this policy's page. - </p> - <div className="modal-cta-wrap"> - <GitOpsModeTooltipWrapper - entityType="software" - position="top" - tipOffset={8} - renderChildren={(disableChildren) => ( - <Button - onClick={onAddPatchPolicy} - isLoading={isAddingPatchPolicy} - disabled={disableChildren} - > - Add - </Button> - )} - /> - <Button variant="inverse" onClick={onExit}> - Cancel - </Button> - </div> - </> - </Modal> - ); -}; - -export default AddPatchPolicyModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss deleted file mode 100644 index 82bd8fbc5c..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.add-patch-policy-modal { - overflow-wrap: anywhere; // Prevent long software name overflow -} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts deleted file mode 100644 index f0ea25ceef..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AddPatchPolicyModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tests.tsx new file mode 100644 index 0000000000..4e8d282bd5 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tests.tsx @@ -0,0 +1,268 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { noop } from "lodash"; + +import { + createMockFleetMaintainedAppDetails, + createMockSoftwarePackage, + createMockSoftwareTitle, +} from "__mocks__/softwareMock"; +import softwareAPI from "services/entities/software"; +import teamPoliciesAPI from "services/entities/team_policies"; +import { createCustomRenderer } from "test/test-utils"; + +import DeployModal from "./DeployModal"; + +const renderModal = ({ + softwarePackage = createMockSoftwarePackage({ fleet_maintained_app_id: 1 }), + gitOpsModeEnabled = false, + onExit = noop, + onSuccess = noop, +}: { + softwarePackage?: ReturnType<typeof createMockSoftwarePackage>; + gitOpsModeEnabled?: boolean; + onExit?: () => void; + onSuccess?: () => void; +} = {}) => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + config: { + gitops: { gitops_mode_enabled: gitOpsModeEnabled }, + }, + }, + }, + }); + return render( + <DeployModal + softwareTitle={createMockSoftwareTitle({ + id: 10, + name: "Firefox", + software_package: softwarePackage, + })} + teamId={1} + onExit={onExit} + onSuccess={onSuccess} + /> + ); +}; + +describe("DeployModal", () => { + beforeEach(() => { + jest.spyOn(softwareAPI, "getFleetMaintainedApp").mockResolvedValue({ + fleet_maintained_app: createMockFleetMaintainedAppDetails(), + }); + jest.spyOn(teamPoliciesAPI, "create").mockResolvedValue({} as never); + jest.spyOn(teamPoliciesAPI, "update").mockResolvedValue({} as never); + jest.spyOn(teamPoliciesAPI, "destroy").mockResolvedValue({} as never); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("reflects an externally-created manual patch policy", () => { + renderModal({ + softwarePackage: createMockSoftwarePackage({ + fleet_maintained_app_id: 1, + patch_policy: { + id: 22, + name: "Firefox up to date", + patch_when_closed: false, + }, + automatic_install_policies: [], + }), + }); + + expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "End user initiated (manual)" }) + ).toBeChecked(); + }); + + it("creates a Force patch policy through the policy endpoint", async () => { + const { user } = renderModal(); + + await user.click(screen.getByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("radio", { name: "Force patch" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(teamPoliciesAPI.create).toHaveBeenCalledWith({ + team_id: 1, + type: "patch", + patch_software_title_id: 10, + software_title_id: 10, + patch_when_closed: false, + continuous_automations_enabled: true, + }) + ); + }); + + it("creates Force install with the current FMA query and platform", async () => { + const { user } = renderModal(); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled() + ); + await user.click(screen.getByRole("checkbox", { name: "force-install" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(teamPoliciesAPI.create).toHaveBeenCalledWith({ + team_id: 1, + name: "[Install software] Firefox", + description: + "Policy triggers automatic install of Firefox on each host that's missing this software.", + query: + "SELECT 1 FROM apps WHERE bundle_identifier = 'com.example.test-app';", + platform: "darwin", + software_title_id: 10, + }) + ); + }); + + it("attaches install automation when a manual patch policy changes to Force patch", async () => { + const { user } = renderModal({ + softwarePackage: createMockSoftwarePackage({ + fleet_maintained_app_id: 1, + patch_policy: { + id: 22, + name: "Firefox up to date", + patch_when_closed: false, + }, + automatic_install_policies: [], + }), + }); + + await user.click(screen.getByRole("radio", { name: "Force patch" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, { + team_id: 1, + software_title_id: 10, + patch_when_closed: false, + continuous_automations_enabled: true, + }) + ); + }); + + it("removes install automation when Force patch changes to manual", async () => { + const { user } = renderModal({ + softwarePackage: createMockSoftwarePackage({ + fleet_maintained_app_id: 1, + patch_policy: { + id: 22, + name: "Firefox up to date", + patch_when_closed: false, + }, + automatic_install_policies: [ + { id: 22, name: "Firefox up to date", type: "patch" }, + ], + }), + }); + + await user.click( + screen.getByRole("radio", { name: "End user initiated (manual)" }) + ); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, { + team_id: 1, + software_title_id: null, + patch_when_closed: false, + continuous_automations_enabled: false, + }) + ); + }); + + it("enables continuous automation when saving a migrated Force patch policy", async () => { + const { user } = renderModal({ + softwarePackage: createMockSoftwarePackage({ + fleet_maintained_app_id: 1, + patch_policy: { + id: 22, + name: "Firefox up to date", + patch_when_closed: false, + continuous_automations_enabled: false, + }, + automatic_install_policies: [ + { id: 22, name: "Firefox up to date", type: "patch" }, + ], + }), + }); + + expect(screen.getByRole("radio", { name: "Force patch" })).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, { + team_id: 1, + software_title_id: 10, + patch_when_closed: false, + continuous_automations_enabled: true, + }) + ); + }); + + it("deletes Force install and Patch policies independently", async () => { + const { user } = renderModal({ + softwarePackage: createMockSoftwarePackage({ + fleet_maintained_app_id: 1, + automatic_install_policies: [ + { id: 11, name: "[Install software] Firefox", type: "dynamic" }, + ], + patch_policy: { + id: 22, + name: "Firefox up to date", + patch_when_closed: true, + }, + }), + }); + + await user.click(screen.getByRole("checkbox", { name: "force-install" })); + await user.click(screen.getByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(teamPoliciesAPI.destroy).toHaveBeenNthCalledWith(1, 1, [11]); + expect(teamPoliciesAPI.destroy).toHaveBeenNthCalledWith(2, 1, [22]); + }); + }); + + it("closes and refreshes after a partial save so retry uses fresh policy state", async () => { + const onExit = jest.fn(); + const onSuccess = jest.fn(); + (teamPoliciesAPI.create as jest.Mock) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("Patch failed")); + const { user } = renderModal({ onExit, onSuccess }); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled() + ); + await user.click(screen.getByRole("checkbox", { name: "force-install" })); + await user.click(screen.getByRole("checkbox", { name: "patch" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(teamPoliciesAPI.create).toHaveBeenCalledTimes(2); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); + }); + }); + + it("disables the Deploy control and Save in GitOps mode", () => { + renderModal({ gitOpsModeEnabled: true }); + + expect( + screen.getByRole("checkbox", { name: "force-install" }) + ).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("checkbox", { name: "patch" })).toHaveAttribute( + "aria-disabled", + "true" + ); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tsx new file mode 100644 index 0000000000..9f3b66fd52 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/DeployModal.tsx @@ -0,0 +1,201 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; + +import { ISoftwareTitleDetails } from "interfaces/software"; +import { getErrorReason } from "interfaces/errors"; +import softwareAPI from "services/entities/software"; +import teamPoliciesAPI from "services/entities/team_policies"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import Button from "components/buttons/Button"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; +import { + getPatchPolicyFlags, + PatchOption, + SoftwareDeploySelector, +} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; +import { + getFleetAppPolicyDescription, + getFleetAppPolicyName, +} from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers"; + +const baseClass = "deploy-modal"; + +interface IDeployModalProps { + softwareTitle: ISoftwareTitleDetails; + teamId: number; + onExit: () => void; + onSuccess: () => void; +} + +const DeployModal = ({ + softwareTitle, + teamId, + onExit, + onSuccess, +}: IDeployModalProps) => { + const softwarePackage = softwareTitle.software_package; + const automaticInstallPolicies = + softwarePackage?.automatic_install_policies ?? []; + const patchPolicy = softwarePackage?.patch_policy; + const forceInstallPolicy = automaticInstallPolicies.find( + (policy) => + policy.type === "dynamic" && + policy.name === getFleetAppPolicyName(softwareTitle.name) + ); + const patchHasAutomation = + !!patchPolicy && + automaticInstallPolicies.some((policy) => policy.id === patchPolicy.id); + let initialPatchOption: PatchOption = "manual"; + if (patchPolicy?.patch_when_closed) { + initialPatchOption = "closed"; + } else if (patchHasAutomation) { + initialPatchOption = "force"; + } + + const [forceInstall, setForceInstall] = useState(!!forceInstallPolicy); + const [patch, setPatch] = useState(!!patchPolicy); + const [patchOption, setPatchOption] = useState<PatchOption>( + initialPatchOption + ); + const [isSaving, setIsSaving] = useState(false); + + const fleetMaintainedAppId = softwarePackage?.fleet_maintained_app_id; + const { + data: fleetMaintainedApp, + isLoading: isLoadingFleetMaintainedApp, + } = useQuery( + ["fleet-maintained-app", fleetMaintainedAppId, teamId], + () => + softwareAPI.getFleetMaintainedApp( + fleetMaintainedAppId as number, + String(teamId) + ), + { + ...DEFAULT_USE_QUERY_OPTIONS, + enabled: !!fleetMaintainedAppId && !forceInstallPolicy, + select: (res) => res.fleet_maintained_app, + } + ); + + const onSave = async () => { + setIsSaving(true); + let savedAnyChange = false; + try { + if (forceInstall !== !!forceInstallPolicy) { + if (forceInstall) { + if (!fleetMaintainedApp?.automatic_install_query) { + throw new Error( + "Couldn't create the Force install policy. Try again." + ); + } + await teamPoliciesAPI.create({ + team_id: teamId, + name: getFleetAppPolicyName(softwareTitle.name), + description: getFleetAppPolicyDescription(softwareTitle.name), + query: fleetMaintainedApp.automatic_install_query, + platform: fleetMaintainedApp.platform, + software_title_id: softwareTitle.id, + }); + } else if (forceInstallPolicy) { + await teamPoliciesAPI.destroy(teamId, [forceInstallPolicy.id]); + } + savedAnyChange = true; + } + + if (patch !== !!patchPolicy) { + if (patch) { + await teamPoliciesAPI.create({ + team_id: teamId, + type: "patch", + patch_software_title_id: softwareTitle.id, + ...(patchOption !== "manual" && { + software_title_id: softwareTitle.id, + }), + ...getPatchPolicyFlags(patchOption), + }); + } else if (patchPolicy) { + await teamPoliciesAPI.destroy(teamId, [patchPolicy.id]); + } + savedAnyChange = true; + } else if ( + patch && + patchPolicy && + (patchOption !== initialPatchOption || + patchHasAutomation !== (patchOption !== "manual") || + patchPolicy.patch_when_closed !== + getPatchPolicyFlags(patchOption).patch_when_closed || + patchPolicy.continuous_automations_enabled !== + getPatchPolicyFlags(patchOption).continuous_automations_enabled) + ) { + await teamPoliciesAPI.update(patchPolicy.id, { + team_id: teamId, + software_title_id: patchOption === "manual" ? null : softwareTitle.id, + ...getPatchPolicyFlags(patchOption), + }); + savedAnyChange = true; + } + + onSuccess(); + onExit(); + } catch (error) { + notify.error(getErrorReason(error), { response: error }); + if (savedAnyChange) { + onSuccess(); + onExit(); + } + } finally { + setIsSaving(false); + } + }; + + return ( + <Modal + className={baseClass} + title="Deploy" + onExit={onExit} + isContentDisabled={isSaving} + > + <> + <GitOpsModeTooltipWrapper + entityType="software" + renderChildren={(disableChildren) => ( + <SoftwareDeploySelector + forceInstall={forceInstall} + patch={patch} + patchOption={patchOption} + onToggleForceInstall={setForceInstall} + onTogglePatch={setPatch} + onSelectPatchOption={setPatchOption} + disabled={disableChildren || isLoadingFleetMaintainedApp} + showPatchWhenClosedNotice + /> + )} + /> + <div className="modal-cta-wrap"> + <GitOpsModeTooltipWrapper + entityType="software" + position="top" + tipOffset={8} + renderChildren={(disableChildren) => ( + <Button + onClick={onSave} + isLoading={isSaving} + disabled={disableChildren || isLoadingFleetMaintainedApp} + > + Save + </Button> + )} + /> + <Button variant="inverse" onClick={onExit}> + Cancel + </Button> + </div> + </> + </Modal> + ); +}; + +export default DeployModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/_styles.scss new file mode 100644 index 0000000000..7e43e9ee4e --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/_styles.scss @@ -0,0 +1,3 @@ +.deploy-modal { + overflow-wrap: anywhere; +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/index.ts new file mode 100644 index 0000000000..c68172759e --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeployModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeployModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index 7f195381ea..6a27145b53 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -62,6 +62,7 @@ interface IEditSoftwareModalProps { * software" — we're editing one specific installer on a title that has * several, not the title's only package. */ canActivateMultiplePackages?: boolean; + patchWhenClosed?: boolean; } const EditSoftwareModal = ({ @@ -79,6 +80,7 @@ const EditSoftwareModal = ({ source, iconUrl = undefined, canActivateMultiplePackages = false, + patchWhenClosed = false, }: IEditSoftwareModalProps) => { const queryClient = useQueryClient(); const { gitOpsModeEnabled } = useGitOpsMode("software"); @@ -219,6 +221,7 @@ const EditSoftwareModal = ({ // progress bar at 97% until the server response is received setUploadProgress(Math.max(progress - 0.03, 0.01)); }, + omitPreInstallQuery: patchWhenClosed, }); notify.success( @@ -367,6 +370,7 @@ const EditSoftwareModal = ({ defaultCategories={softwarePackage.categories} gitopsCompatible={isGitOpsCompatible} teamId={teamId} + patchWhenClosed={patchWhenClosed} /> ); } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/PoliciesModal/PoliciesModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/PoliciesModal/PoliciesModal.tsx index 47db395a46..846365d618 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/PoliciesModal/PoliciesModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/PoliciesModal/PoliciesModal.tsx @@ -14,8 +14,6 @@ interface IPoliciesModalProps { onExit: () => void; } -// TODO: Marko to update the design of this modal — layout, plus a -// description that accounts for patch policies that may not auto-update. const PoliciesModal = ({ policies, teamId, onExit }: IPoliciesModalProps) => { return ( <Modal className={baseClass} title="Policies" onExit={onExit}> diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx index 99d66c7ae9..2a321770f8 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx @@ -257,7 +257,7 @@ describe("Software Summary Card", () => { expect(options).not.toContain("Edit configuration"); }); - it("adds Versions option after Patch for a Premium Fleet-maintained app", async () => { + it("adds Versions after Deploy for a Premium Fleet-maintained app", async () => { const { user } = render( <SoftwareSummaryCard softwareTitle={createMockSoftwareTitle({ @@ -274,12 +274,12 @@ describe("Software Summary Card", () => { ); const options = await getDropdownOptions(user); - const patchIdx = options.indexOf("Patch"); + const deployIdx = options.indexOf("Deploy"); const versionsIdx = options.indexOf("Versions"); - expect(patchIdx).toBeGreaterThan(-1); + expect(deployIdx).toBeGreaterThan(-1); expect(versionsIdx).toBeGreaterThan(-1); - expect(versionsIdx).toBe(patchIdx + 1); + expect(versionsIdx).toBe(deployIdx + 1); }); it("hides Versions option on Fleet Free even for a Fleet-maintained app", async () => { @@ -315,6 +315,7 @@ describe("Software Summary Card", () => { const options = await getDropdownOptions(user); expect(options).not.toContain("Versions"); + expect(options).not.toContain("Deploy"); }); it("hides the Actions dropdown (and therefore Versions) for non-FMA custom installers", () => { @@ -540,7 +541,11 @@ describe("Software Summary Card", () => { automatic_install_policies: [ { id: 1, name: "Policy A", type: "dynamic" }, ], - patch_policy: { id: 42, name: "Outdated Postman" }, + patch_policy: { + id: 42, + name: "Outdated Postman", + patch_when_closed: false, + }, }), })} softwareId={1} @@ -607,7 +612,11 @@ describe("Software Summary Card", () => { softwareTitle={createMockSoftwareTitle({ software_package: createMockSoftwarePackage({ fleet_maintained_app_id: 7, - patch_policy: { id: 42, name: "Outdated Postman" }, + patch_policy: { + id: 42, + name: "Outdated Postman", + patch_when_closed: false, + }, }), })} softwareId={1} @@ -631,7 +640,11 @@ describe("Software Summary Card", () => { automatic_install_policies: [ { id: 1, name: "Policy A", type: "dynamic" }, ], - patch_policy: { id: 42, name: "Outdated Postman" }, + patch_policy: { + id: 42, + name: "Outdated Postman", + patch_when_closed: false, + }, }), })} softwareId={1} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx index 1330dfe54e..ce9b661cae 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx @@ -28,7 +28,7 @@ import EditIconModal from "../EditIconModal"; import EditSoftwareModal from "../EditSoftwareModal"; import EditConfigurationModal from "../EditConfigurationModal"; import EditAutoUpdateConfigModal from "../EditAutoUpdateConfigModal"; -import AddPatchPolicyModal from "../AddPatchPolicyModal"; +import DeployModal from "../DeployModal"; import PoliciesModal from "../PoliciesModal"; interface ISoftwareSummaryCard { @@ -78,7 +78,7 @@ const SoftwareSummaryCard = ({ const [iconUploadedAt, setIconUploadedAt] = useState(""); const [showEditIconModal, setShowEditIconModal] = useState(false); const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false); - const [showAddPatchPolicyModal, setShowAddPatchPolicyModal] = useState(false); + const [showDeployModal, setShowDeployModal] = useState(false); const [showEditConfigurationModal, setShowEditConfigurationModal] = useState( false ); @@ -265,7 +265,8 @@ const SoftwareSummaryCard = ({ const canEditConfiguration = canManageSoftware && ((isAndroidPlayStoreApp && !isAndroidPlayStoreWebApp) || isIosOrIpadosApp); - const canPatchSoftware = canManageSoftware && isFleetMaintainedApp; + const canDeploySoftware = + canManageSoftware && isFleetMaintainedApp && !!isPremiumTier; /** Versions / pin is a Premium-only Fleet-maintained app feature */ const canManageVersions = canManageSoftware && isFleetMaintainedApp && !!isPremiumTier; @@ -278,7 +279,7 @@ const SoftwareSummaryCard = ({ const onClickEditAppearance = () => setShowEditIconModal(true); const onClickEditSoftware = () => setShowEditSoftwareModal(true); - const onClickAddPatchPolicy = () => setShowAddPatchPolicyModal(true); + const onClickDeploy = () => setShowDeployModal(true); const onClickEditConfiguration = () => setShowEditConfigurationModal(true); const onClickEditAutoUpdateConfig = () => setShowEditAutoUpdateConfigModal(true); @@ -315,9 +316,7 @@ const SoftwareSummaryCard = ({ : undefined } useSingleEditAppearanceButton={canActivateMultiplePackages} - onClickAddPatchPolicy={ - canPatchSoftware ? onClickAddPatchPolicy : undefined - } + onClickDeploy={canDeploySoftware ? onClickDeploy : undefined} onClickVersions={canManageVersions ? onClickVersions : undefined} onClickEditConfiguration={ canEditConfiguration ? onClickEditConfiguration : undefined @@ -325,7 +324,6 @@ const SoftwareSummaryCard = ({ onClickEditAutoUpdateConfig={ canEditAutoUpdateConfig ? onClickEditAutoUpdateConfig : undefined } - patchPolicyId={softwareTitle.software_package?.patch_policy?.id} headerPills={headerPills} isAppleVpp={isAppleVpp} /> @@ -366,14 +364,17 @@ const SoftwareSummaryCard = ({ displayName={softwareDisplayName} source={softwareTitle.source} iconUrl={softwareTitle.icon_url} + patchWhenClosed={ + softwareTitle.software_package?.patch_policy?.patch_when_closed + } /> )} - {showAddPatchPolicyModal && softwareInstallerOnTeam && ( - <AddPatchPolicyModal - softwareId={softwareTitle.id} + {showDeployModal && softwareInstallerOnTeam && ( + <DeployModal + softwareTitle={softwareTitle} teamId={teamId} onSuccess={refetchSoftwareTitle} - onExit={() => setShowAddPatchPolicyModal(false)} + onExit={() => setShowDeployModal(false)} /> )} {showEditConfigurationModal && softwareInstallerOnTeam && ( diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tests.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tests.tsx index 5d7c7dfd0c..aa60ed60c0 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tests.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tests.tsx @@ -6,7 +6,7 @@ import SoftwareDetailsSummary, { ACTION_EDIT_APPEARANCE, ACTION_EDIT_SOFTWARE, ACTION_EDIT_CONFIGURATION, - ACTION_PATCH, + ACTION_DEPLOY, ACTION_VERSIONS, ACTION_EDIT_AUTO_UPDATE_CONFIGURATION, } from "./SoftwareDetailsSummary"; @@ -24,10 +24,9 @@ describe("buildActionOptions", () => { repoURL: undefined, canEditSoftware: false, canEditConfiguration: false, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); expect(result).toEqual([ @@ -46,10 +45,9 @@ describe("buildActionOptions", () => { repoURL: undefined, canEditSoftware: true, canEditConfiguration: false, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); const values = result.map((o) => o.value); @@ -72,10 +70,9 @@ describe("buildActionOptions", () => { repoURL: undefined, canEditSoftware: false, canEditConfiguration: true, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); const values = result.map((o) => o.value); @@ -99,10 +96,9 @@ describe("buildActionOptions", () => { isAppleVpp: true, canEditSoftware: true, canEditConfiguration: true, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); const editAppearance = result.find( @@ -132,67 +128,41 @@ describe("buildActionOptions", () => { }); }); - it("adds Patch option enabled when canAddPatchPolicy and no existing patch policy", () => { + it("adds Deploy when software can be deployed", () => { const result = buildActionOptions({ gitOpsModeEnabled: false, repoURL: undefined, canEditSoftware: false, canEditConfiguration: false, - canAddPatchPolicy: true, + canDeploySoftware: true, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); - const patch = result.find((opt) => opt.value === ACTION_PATCH); + const deploy = result.find((opt) => opt.value === ACTION_DEPLOY); - expect(patch).toEqual({ - label: "Patch", - value: ACTION_PATCH, - isDisabled: false, - tooltipContent: undefined, + expect(deploy).toEqual({ + label: "Deploy", + value: ACTION_DEPLOY, }); }); - it("adds Patch option disabled with tooltip when hasExistingPatchPolicy", () => { - const result = buildActionOptions({ - gitOpsModeEnabled: false, - repoURL: undefined, - canEditSoftware: false, - canEditConfiguration: false, - canAddPatchPolicy: true, - canManageVersions: false, - canConfigureAutoUpdate: false, - hasExistingPatchPolicy: true, - }); - - const patch = result.find((opt) => opt.value === ACTION_PATCH); - - expect(patch).toEqual({ - label: "Patch", - value: ACTION_PATCH, - isDisabled: true, - tooltipContent: "Patch policy is already added.", - }); - }); - - it("adds Versions option after Patch when canManageVersions", () => { + it("adds Versions option after Deploy when canManageVersions", () => { const result = buildActionOptions({ gitOpsModeEnabled: false, repoURL: undefined, canEditSoftware: true, canEditConfiguration: false, - canAddPatchPolicy: true, + canDeploySoftware: true, canManageVersions: true, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); const values = result.map((o) => o.value); expect(values).toEqual([ ACTION_EDIT_APPEARANCE, ACTION_EDIT_SOFTWARE, - ACTION_PATCH, + ACTION_DEPLOY, ACTION_VERSIONS, ]); @@ -209,10 +179,9 @@ describe("buildActionOptions", () => { repoURL: undefined, canEditSoftware: false, canEditConfiguration: false, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); expect(result.find((o) => o.value === ACTION_VERSIONS)).toBeUndefined(); @@ -224,10 +193,9 @@ describe("buildActionOptions", () => { repoURL: "https://repo.git", canEditSoftware: false, canEditConfiguration: false, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: true, canConfigureAutoUpdate: false, - hasExistingPatchPolicy: false, }); const versions = result.find((opt) => opt.value === ACTION_VERSIONS); @@ -243,10 +211,9 @@ describe("buildActionOptions", () => { repoURL: undefined, canEditSoftware: false, canEditConfiguration: false, - canAddPatchPolicy: false, + canDeploySoftware: false, canManageVersions: false, canConfigureAutoUpdate: true, - hasExistingPatchPolicy: false, }); const autoUpdate = result.find( diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index 951684796d..5d2e6c5864 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -40,7 +40,7 @@ import OSIcon from "../../icons/OSIcon"; export const ACTION_EDIT_APPEARANCE = "edit_appearance"; export const ACTION_EDIT_SOFTWARE = "edit_software"; export const ACTION_EDIT_CONFIGURATION = "edit_configuration"; -export const ACTION_PATCH = "patch"; +export const ACTION_DEPLOY = "deploy"; export const ACTION_VERSIONS = "versions"; export const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION = "edit_auto_update_configuration"; @@ -57,10 +57,9 @@ export interface BuildActionOptionsArgs { isAppleVpp?: boolean; canEditSoftware: boolean; canEditConfiguration: boolean; - canAddPatchPolicy: boolean; + canDeploySoftware: boolean; canManageVersions: boolean; canConfigureAutoUpdate: boolean; - hasExistingPatchPolicy?: boolean; } export const buildActionOptions = ({ @@ -69,14 +68,12 @@ export const buildActionOptions = ({ isAppleVpp = false, canEditSoftware, canEditConfiguration, - canAddPatchPolicy, + canDeploySoftware, canManageVersions, canConfigureAutoUpdate, - hasExistingPatchPolicy = false, }: BuildActionOptionsArgs): CustomOptionType[] => { let disableEditAppearanceTooltipContent: TooltipContent | undefined; let disableEditSoftwareTooltipContent: TooltipContent | undefined; - let disabledPatchPolicyTooltipContent: TooltipContent | undefined; let disabledEditConfigurationTooltipContent: TooltipContent | undefined; // Disable state is keyed off `gitOpsModeEnabled` directly (see each option @@ -95,10 +92,6 @@ export const buildActionOptions = ({ } } - if (hasExistingPatchPolicy) { - disabledPatchPolicyTooltipContent = "Patch policy is already added."; - } - const options: CustomOptionType[] = [ { label: "Edit appearance", @@ -128,13 +121,11 @@ export const buildActionOptions = ({ }); } - // Show patch option only for fleet maintained apps - if (canAddPatchPolicy) { + // Show Deploy only for Fleet-maintained apps. + if (canDeploySoftware) { options.push({ - label: "Patch", - value: ACTION_PATCH, - isDisabled: !!disabledPatchPolicyTooltipContent, - tooltipContent: disabledPatchPolicyTooltipContent, + label: "Deploy", + value: ACTION_DEPLOY, }); } @@ -185,8 +176,8 @@ interface ISoftwareDetailsSummaryProps { /** Displays an edit CTA to edit the software installer * Should only be defined for team view of an installable software */ onClickEditSoftware?: () => void; - /** Displays Patch CTA to add a patch policy */ - onClickAddPatchPolicy?: () => void; + /** Displays Deploy CTA for Fleet-maintained apps. */ + onClickDeploy?: () => void; /** Displays Versions CTA to open the versions / pin modal (Premium FMA only) */ onClickVersions?: () => void; /** undefined unless previewing icon, in which case is string or null */ @@ -197,7 +188,6 @@ interface ISoftwareDetailsSummaryProps { iconPreviewUrl?: string | null; /** timestamp of when icon was last uploaded, used to force refresh of cached icon */ iconUploadedAt?: string; - patchPolicyId?: number; /** Optional pill row rendered between the title and the Actions dropdown * (e.g. Fleet-maintained, Self-service, Auto install). */ headerPills?: React.ReactNode; @@ -224,13 +214,12 @@ const SoftwareDetailsSummary = ({ canManageSoftware = false, onClickEditAppearance, onClickEditSoftware, - onClickAddPatchPolicy, + onClickDeploy, onClickVersions, onClickEditConfiguration, onClickEditAutoUpdateConfig, iconPreviewUrl, iconUploadedAt, - patchPolicyId, headerPills, isAppleVpp = false, useSingleEditAppearanceButton = false, @@ -248,8 +237,8 @@ const SoftwareDetailsSummary = ({ case ACTION_EDIT_SOFTWARE: onClickEditSoftware && onClickEditSoftware(); break; - case ACTION_PATCH: - onClickAddPatchPolicy && onClickAddPatchPolicy(); + case ACTION_DEPLOY: + onClickDeploy && onClickDeploy(); break; case ACTION_VERSIONS: onClickVersions && onClickVersions(); @@ -300,10 +289,9 @@ const SoftwareDetailsSummary = ({ isAppleVpp, canEditSoftware: !!onClickEditSoftware, canEditConfiguration: !!onClickEditConfiguration, - canAddPatchPolicy: !!onClickAddPatchPolicy, + canDeploySoftware: !!onClickDeploy, canManageVersions: !!onClickVersions, canConfigureAutoUpdate: !!onClickEditAutoUpdateConfig, - hasExistingPatchPolicy: !!patchPolicyId, }); return ( diff --git a/frontend/pages/SoftwarePage/components/forms/AdvancedOptionsFields/AdvancedOptionsFields.tsx b/frontend/pages/SoftwarePage/components/forms/AdvancedOptionsFields/AdvancedOptionsFields.tsx index 2b353db577..2917603557 100644 --- a/frontend/pages/SoftwarePage/components/forms/AdvancedOptionsFields/AdvancedOptionsFields.tsx +++ b/frontend/pages/SoftwarePage/components/forms/AdvancedOptionsFields/AdvancedOptionsFields.tsx @@ -30,6 +30,7 @@ interface IAdvancedOptionsFieldsProps { onChangeUninstallScript: (value?: string) => void; gitopsCompatible?: boolean; gitOpsModeEnabled?: boolean; + patchWhenClosed?: boolean; } const AdvancedOptionsFields = ({ @@ -53,6 +54,7 @@ const AdvancedOptionsFields = ({ onChangeUninstallScript, gitopsCompatible = false, gitOpsModeEnabled = false, + patchWhenClosed = false, }: IAdvancedOptionsFieldsProps) => { const classNames = classnames(baseClass, className); @@ -84,8 +86,20 @@ const AdvancedOptionsFields = ({ maxLines={10} onChange={onChangePreInstallQuery} labelActionComponent={renderLabelComponent()} - helpText="Software will be installed only if the query returns results." - readOnly={disableFields} + helpText={ + <> + Software will be installed only if the query returns results. + {patchWhenClosed && ( + <> + {" "} + Pre-install query won't run when install is triggered via + self-service, manually on the host, or during the setup + experience. + </> + )} + </> + } + readOnly={disableFields || patchWhenClosed} /> <Editor wrapEnabled diff --git a/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx index 3a0240ca92..1c1083b439 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx @@ -219,6 +219,7 @@ interface IPackageAdvancedOptionsProps { /** Currently for editing FMA only, users cannot edit */ gitopsCompatible?: boolean; gitOpsModeEnabled?: boolean; + patchWhenClosed?: boolean; } const PackageAdvancedOptions = ({ @@ -236,6 +237,7 @@ const PackageAdvancedOptions = ({ onChangeUninstallScript, gitopsCompatible = false, gitOpsModeEnabled = false, + patchWhenClosed = false, }: IPackageAdvancedOptionsProps) => { const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const name = selectedPackage?.name || ""; @@ -269,6 +271,7 @@ const PackageAdvancedOptions = ({ onChangeUninstallScript={onChangeUninstallScript} gitopsCompatible={gitopsCompatible} gitOpsModeEnabled={gitOpsModeEnabled} + patchWhenClosed={patchWhenClosed} /> ); }; diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx index 3907f7ff0b..6842153663 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx @@ -38,6 +38,7 @@ describe("PackageForm", () => { expect(screen.queryByText(TARGET_BANNER_COPY)).not.toBeInTheDocument(); expect(screen.queryByLabelText("All hosts")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument(); + expect(screen.queryByText("Self-service")).not.toBeInTheDocument(); }); it("renders the Target section with the first-added banner once a file is selected", () => { @@ -49,6 +50,7 @@ describe("PackageForm", () => { expect(screen.getByText(TARGET_BANNER_COPY)).toBeInTheDocument(); expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); expect(screen.getByLabelText("Custom")).toBeInTheDocument(); + expect(screen.getByText("Self-service")).toBeInTheDocument(); }); it("omits the first-added banner on the Edit flow", () => { diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index b4a62aceac..9a0a9240e4 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -151,6 +151,7 @@ interface IPackageFormProps { /** Overrides the initial `targetType` for new (non-editing) forms. The * multi-package add modal preselects `"Custom"` per Figma. */ initialTargetType?: string; + patchWhenClosed?: boolean; } // application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly const ACCEPTED_EXTENSIONS = @@ -179,6 +180,7 @@ const PackageForm = ({ restrictedFileAccept, restrictedFileTypeLabel, initialTargetType, + patchWhenClosed = false, }: IPackageFormProps) => { const { gitOpsModeEnabled, repoURL } = useGitOpsMode("software"); @@ -408,11 +410,13 @@ const PackageForm = ({ ); // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector. - // Options selector (self-service + categories) stays edit-only. The target - // selector shows whenever a package is being staged — on Edit, in the + // The options selector exposes Self-service on Add and Edit; categories + // remain edit-only inside SoftwareOptionsSelector. The target selector + // shows whenever a package is being staged — on Edit, in the // multi-package Add modal, and on the single-package Add page once a file // is chosen — because every package on a title needs its own label scope. - const showSoftwareOptionsSelector = !gitOpsModeEnabled && isEditingSoftware; + const showSoftwareOptionsSelector = + !gitOpsModeEnabled && (isEditingSoftware || !!formData.software); const showTargetLabelSelector = !gitOpsModeEnabled && (isEditingSoftware || multiPackageContext || !!formData.software); @@ -525,6 +529,7 @@ const PackageForm = ({ onChangeUninstallScript={onChangeUninstallScript} gitopsCompatible={gitopsCompatible} gitOpsModeEnabled={gitOpsModeEnabled} + patchWhenClosed={patchWhenClosed} /> )} <div className={`${baseClass}__action-buttons`}> diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx new file mode 100644 index 0000000000..ae325551b8 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import SoftwareDeploySelector, { PatchOption } from "./SoftwareDeploySelector"; + +const renderSelector = ( + overrides: Partial<React.ComponentProps<typeof SoftwareDeploySelector>> = {} +) => { + const props: React.ComponentProps<typeof SoftwareDeploySelector> = { + forceInstall: false, + patch: false, + patchOption: "closed", + onToggleForceInstall: jest.fn(), + onTogglePatch: jest.fn(), + onSelectPatchOption: jest.fn(), + ...overrides, + }; + return { ...render(<SoftwareDeploySelector {...props} />), props }; +}; + +describe("SoftwareDeploySelector", () => { + it("shows Force install without patch options", () => { + renderSelector({ forceInstall: true }); + + expect( + screen.getByRole("checkbox", { name: "force-install" }) + ).toBeChecked(); + expect(screen.queryByRole("radio")).not.toBeInTheDocument(); + }); + + it("shows patch options with Patch when app is closed selected by default", () => { + renderSelector({ patch: true }); + + expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "Patch when app is closed" }) + ).toBeChecked(); + }); + + it("supports Force install and Patch together", () => { + renderSelector({ forceInstall: true, patch: true }); + + expect( + screen.getByRole("checkbox", { name: "force-install" }) + ).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked(); + expect(screen.getAllByRole("radio")).toHaveLength(3); + }); + + it("shows the Force patch information banner", () => { + renderSelector({ patch: true, patchOption: "force" }); + + expect( + screen.getByText( + "End user is not notified. Patch is forced as soon as policy fails. Notifications are coming soon." + ) + ).toBeInTheDocument(); + }); + + it("shows the pre-install query override notice in the Deploy modal", () => { + renderSelector({ patch: true, showPatchWhenClosedNotice: true }); + + expect( + screen.getByText(/overrides the pre-install query \(advanced option\)/) + ).toBeInTheDocument(); + }); + + it("reports the selected patch option", async () => { + const onSelectPatchOption = jest.fn<void, [PatchOption]>(); + const { getByRole } = renderSelector({ + patch: true, + onSelectPatchOption, + }); + + getByRole("radio", { name: "Force patch" }).click(); + expect(onSelectPatchOption).toHaveBeenCalledWith("force"); + }); +}); diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx new file mode 100644 index 0000000000..142d1086c9 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx @@ -0,0 +1,135 @@ +import React from "react"; + +import Checkbox from "components/forms/fields/Checkbox"; +import Radio from "components/forms/fields/Radio"; +import InfoBanner from "components/InfoBanner"; + +const baseClass = "software-deploy-selector"; + +export type PatchOption = "closed" | "force" | "manual"; + +export const getPatchPolicyFlags = (patchOption: PatchOption) => ({ + patch_when_closed: patchOption === "closed", + continuous_automations_enabled: patchOption !== "manual", +}); + +interface ISoftwareDeploySelectorProps { + forceInstall: boolean; + patch: boolean; + patchOption: PatchOption; + onToggleForceInstall: (value: boolean) => void; + onTogglePatch: (value: boolean) => void; + onSelectPatchOption: (value: PatchOption) => void; + disabled?: boolean; + showPatchWhenClosedNotice?: boolean; +} + +interface IPatchOptionSelectorProps { + patchOption: PatchOption; + onSelectPatchOption: (value: PatchOption) => void; + disabled?: boolean; + showPatchWhenClosedNotice?: boolean; +} + +export const PatchOptionSelector = ({ + patchOption, + onSelectPatchOption, + disabled = false, + showPatchWhenClosedNotice = false, +}: IPatchOptionSelectorProps) => { + const onChangePatchOption = (value: string) => + onSelectPatchOption(value as PatchOption); + + return ( + <div + className={`${baseClass}__patch-options`} + role="radiogroup" + aria-label="Patch options" + > + <Radio + id="patch-when-closed" + name="patch-option" + value="closed" + label="Patch when app is closed" + checked={patchOption === "closed"} + onChange={onChangePatchOption} + disabled={disabled} + /> + <Radio + id="force-patch" + name="patch-option" + value="force" + label="Force patch" + checked={patchOption === "force"} + onChange={onChangePatchOption} + disabled={disabled} + /> + <Radio + id="manual-patch" + name="patch-option" + value="manual" + label="End user initiated (manual)" + checked={patchOption === "manual"} + onChange={onChangePatchOption} + disabled={disabled} + /> + {patchOption === "force" && ( + <InfoBanner color="yellow"> + End user is not notified. Patch is forced as soon as policy fails. + Notifications are coming soon. + </InfoBanner> + )} + {patchOption === "closed" && showPatchWhenClosedNotice && ( + <p className={`${baseClass}__patch-when-closed-notice`}> + <b>Patch when app is closed</b> overrides the pre-install query + (advanced option) to check if the app is closed. + </p> + )} + </div> + ); +}; + +const SoftwareDeploySelector = ({ + forceInstall, + patch, + patchOption, + onToggleForceInstall, + onTogglePatch, + onSelectPatchOption, + disabled = false, + showPatchWhenClosedNotice = false, +}: ISoftwareDeploySelectorProps) => { + return ( + <div className={`form-field ${baseClass}`}> + <div className="form-field__label">Deploy</div> + <div className={`${baseClass}__checkboxes`}> + <Checkbox + name="force-install" + value={forceInstall} + onChange={onToggleForceInstall} + disabled={disabled} + > + Force install + </Checkbox> + <Checkbox + name="patch" + value={patch} + onChange={onTogglePatch} + disabled={disabled} + > + Patch + </Checkbox> + </div> + {patch && ( + <PatchOptionSelector + patchOption={patchOption} + onSelectPatchOption={onSelectPatchOption} + disabled={disabled} + showPatchWhenClosedNotice={showPatchWhenClosedNotice} + /> + )} + </div> + ); +}; + +export default SoftwareDeploySelector; diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss index 9b85bdff22..2c75223215 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss @@ -1,2 +1,26 @@ .software-deploy-slider { } + +.software-deploy-selector { + &__checkboxes { + display: flex; + gap: $pad-xlarge; + + .form-field--checkbox { + margin-bottom: 0; + width: auto; + } + } + + &__patch-options { + display: flex; + flex-direction: column; + gap: $pad-medium; + margin-top: $pad-medium; + margin-left: $pad-xlarge; + } + + &__patch-when-closed-notice { + margin: 0; + } +} diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts index fd84411aad..59ae55fd90 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts @@ -1 +1,7 @@ export { default } from "./SoftwareDeploySlider"; +export { + default as SoftwareDeploySelector, + PatchOptionSelector, +} from "./SoftwareDeploySelector"; +export { getPatchPolicyFlags } from "./SoftwareDeploySelector"; +export type { PatchOption } from "./SoftwareDeploySelector"; diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx new file mode 100644 index 0000000000..60b4640139 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createMockVppApp } from "__mocks__/appleMdm"; +import { createCustomRenderer } from "test/test-utils"; + +import SoftwareVppForm from "./SoftwareVppForm"; + +describe("SoftwareVppForm", () => { + it("shows Self-service after selecting an app to add", async () => { + const render = createCustomRenderer({ withBackendMock: true }); + const { user } = render( + <SoftwareVppForm + labels={[]} + vppApps={[createMockVppApp()]} + onSubmit={jest.fn()} + onCancel={jest.fn()} + onClickPreviewEndUserExperience={jest.fn()} + teamId={1} + /> + ); + + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("radio", { name: /Test App/ })); + + expect(screen.getByText("Self-service")).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx index 3c2b418e69..3ec1bf47db 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx @@ -319,6 +319,20 @@ const SoftwareVppForm = ({ These apps were added in Apple Business (AB). To add more apps, head to <CustomLink url="https://business.apple.com" text="AB" newTab /> </div> + {formData.selectedApp && ( + <SoftwareOptionsSelector + platform={formData.selectedApp.platform} + formData={formData} + onToggleSelfService={onToggleSelfService} + onSelectCategory={onSelectCategory} + onClickPreviewEndUserExperience={() => + onClickPreviewEndUserExperience( + isIpadOrIphoneSoftware(formData.selectedApp?.platform || "") + ) + } + teamId={teamId} + /> + )} {showDeploySoftwareSlider && ( <SoftwareDeploySlider deploySoftware={formData.automaticInstall} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tests.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tests.tsx new file mode 100644 index 0000000000..0b5132fe56 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tests.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { noop } from "lodash"; + +import { createMockHostPastActivity } from "__mocks__/activityMock"; +import { ActivityType } from "interfaces/activity"; + +import InstalledSoftwareActivityItem from "./InstalledSoftwareActivityItem"; + +const createInstallActivity = (installSkippedWhenAppOpen?: boolean) => + createMockHostPastActivity({ + type: ActivityType.InstalledSoftware, + actor_full_name: "Fleet", + fleet_initiated: true, + details: { + software_title: "Firefox", + software_package: "Firefox.pkg", + host_display_name: "Test Host", + source: "apps", + status: "failed_install", + install_uuid: "uuid-123", + install_skipped_when_app_open: installSkippedWhenAppOpen, + }, + }); + +describe("InstalledSoftwareActivityItem", () => { + it("renders skipped copy when the app was open", () => { + render( + <InstalledSoftwareActivityItem + activity={createInstallActivity(true)} + tab="past" + onShowDetails={noop} + /> + ); + + expect(screen.getByText(/skipped install of/)).toBeInTheDocument(); + expect(screen.getByText("Firefox")).toBeInTheDocument(); + expect(screen.getByText("Test Host")).toBeInTheDocument(); + expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument(); + }); + + it("keeps generic failed-install copy when the flag is absent", () => { + render( + <InstalledSoftwareActivityItem + activity={createInstallActivity()} + tab="past" + onShowDetails={noop} + /> + ); + + expect(screen.getByText(/failed to install/)).toBeInTheDocument(); + expect(screen.queryByText(/skipped install/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index 68ba9fec6e..2dee7756f8 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -31,6 +31,22 @@ const InstalledSoftwareActivityItem = ({ details.status === "failed" ? "failed_uninstall" : details.status; const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || ""); + if (details.install_skipped_when_app_open) { + return ( + <ActivityItem + className={baseClass} + activity={activity} + hideCancel={hideCancel} + onShowDetails={onShowDetails} + onCancel={onCancel} + isSoloActivity={isSoloActivity} + > + <b>Fleet</b> skipped install of <b>{title}</b> on{" "} + <b>{details.host_display_name || "this host"}</b>. + </ActivityItem> + ); + } + // Self-service installs/uninstalls can be triggered by anyone who opens the // host's My device page, including admins. Drop the actor and switch to // passive voice so the activity reads "<software> was installed on this diff --git a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx index 2f72b46a86..7aeaaf1084 100644 --- a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx @@ -150,7 +150,10 @@ const render = createCustomRenderer({ * external `handleRef` always sees the latest closure. */ const renderWithHandle = ( policyOverrides?: Partial<IPolicy>, - handleRef?: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> + handleRef?: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null>, + componentProps?: Partial< + React.ComponentPropsWithoutRef<typeof PolicyAutomationsFields> + > ) => { return render( <PolicyAutomationsFields @@ -161,6 +164,7 @@ const renderWithHandle = ( automationsConfig={undefined} globalConfig={undefined} fleetName="Test Fleet" + {...componentProps} /> ); }; @@ -337,4 +341,131 @@ describe("PolicyAutomationsFields — payload", () => { // installer_id as null on the wire. expect(payload?.policyUpdate?.software_installer_id ?? null).toBeNull(); }); + + it("maps Patch when app is closed to both policy flags", () => { + const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = { + current: null, + }; + renderWithHandle( + { + type: "patch", + patch_software: { name: "Firefox", software_title_id: 42 }, + patch_when_closed: false, + continuous_automations_enabled: false, + }, + handleRef, + { patchOption: "closed" } + ); + + expect( + handleRef.current?.getAutomationsPayload().policyUpdate + ).toMatchObject({ + software_title_id: 42, + patch_when_closed: true, + continuous_automations_enabled: true, + }); + }); + + it("maps Force patch to patch_when_closed false and continuous automation true", () => { + const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = { + current: null, + }; + renderWithHandle( + { + type: "patch", + patch_software: { name: "Firefox", software_title_id: 42 }, + patch_when_closed: true, + continuous_automations_enabled: true, + }, + handleRef, + { patchOption: "force" } + ); + + expect( + handleRef.current?.getAutomationsPayload().policyUpdate + ).toMatchObject({ + software_title_id: 42, + patch_when_closed: false, + continuous_automations_enabled: true, + }); + }); + + it("maps End user initiated to no continuous automation", () => { + const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = { + current: null, + }; + renderWithHandle( + { + type: "patch", + patch_software: { name: "Firefox", software_title_id: 42 }, + install_software: { name: "Firefox", software_title_id: 42 }, + patch_when_closed: false, + continuous_automations_enabled: true, + }, + handleRef, + { patchOption: "manual" } + ); + + expect( + handleRef.current?.getAutomationsPayload().policyUpdate + ).toMatchObject({ + software_title_id: null, + continuous_automations_enabled: false, + }); + + expect( + screen.queryByRole("checkbox", { + name: "continuous-automations-enabled", + }) + ).not.toBeInTheDocument(); + }); + + it("checks and disables continuous automation for Patch when app is closed", async () => { + const { user, container } = renderWithHandle( + { + type: "patch", + patch_when_closed: true, + continuous_automations_enabled: true, + }, + undefined, + { patchOption: "closed" } + ); + + const continuous = screen.getByRole("checkbox", { + name: "continuous-automations-enabled", + }); + expect(continuous).toHaveAttribute("aria-checked", "true"); + expect(continuous).toHaveAttribute("aria-disabled", "true"); + + const icon = container.querySelector( + ".policy-automations-fields__section:last-child .fleet-checkbox__icon" + ); + expect(icon).not.toBeNull(); + await user.hover(icon as Element); + expect( + await screen.findByText( + "Continuous automation can't be disabled when Patch when app is closed is selected." + ) + ).toBeInTheDocument(); + }); + + it("keeps continuous automation editable for Force patch", async () => { + const onPatchOptionChange = jest.fn(); + const { user } = renderWithHandle( + { + type: "patch", + patch_when_closed: false, + continuous_automations_enabled: true, + }, + undefined, + { patchOption: "force", onPatchOptionChange } + ); + + const continuous = screen.getByRole("checkbox", { + name: "continuous-automations-enabled", + }); + expect(continuous).toHaveAttribute("aria-disabled", "false"); + await user.click(continuous); + expect(onPatchOptionChange).toHaveBeenCalledWith("manual"); + }); }); diff --git a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx index a5bbc3a74f..61048fbc17 100644 --- a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx @@ -34,6 +34,7 @@ import { getTicketOrWebhookLabel, } from "pages/policies/helpers"; import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; +import { PatchOption } from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; import { IPolicyAutomationUpdate } from "pages/policies/hooks"; @@ -85,6 +86,9 @@ interface IPolicyAutomationsFieldsProps { globalConfig: IConfig | undefined; /** Fleet display name, used in the "Not enabled for <fleet>" hints. */ fleetName: string; + /** Present only for patch policies on Premium. */ + patchOption?: PatchOption; + onPatchOptionChange?: (patchOption: PatchOption) => void; } const PolicyAutomationsFields = forwardRef< @@ -99,6 +103,8 @@ const PolicyAutomationsFields = forwardRef< automationsConfig, globalConfig, fleetName, + patchOption, + onPatchOptionChange, }, ref ) => { @@ -147,6 +153,7 @@ const PolicyAutomationsFields = forwardRef< const initialCalendar = policy.calendar_events_enabled; const initialConditionalAccess = policy.conditional_access_enabled; const initialContinuous = policy.continuous_automations_enabled ?? false; + const initialPatchWhenClosed = policy.patch_when_closed ?? false; const [webhookOrTicketEnabled, setWebhookOrTicketEnabled] = useState( initialWebhookOrTicket @@ -162,6 +169,15 @@ const PolicyAutomationsFields = forwardRef< const [continuousEnabled, setContinuousEnabled] = useState( initialContinuous ); + const patchWhenClosed = patchOption + ? patchOption === "closed" + : initialPatchWhenClosed; + let effectiveContinuousEnabled = continuousEnabled; + if (patchWhenClosed) { + effectiveContinuousEnabled = true; + } else if (patchOption) { + effectiveContinuousEnabled = patchOption === "force"; + } const [softwareTitleId, setSoftwareTitleId] = useState<number | null>( policy.install_software?.software_title_id ?? null @@ -173,6 +189,22 @@ const PolicyAutomationsFields = forwardRef< const [softwareInstallerId, setSoftwareInstallerId] = useState< number | null >(policy.install_software?.software_installer_id ?? null); + const patchSoftwareTitleId = + policy.patch_software?.software_title_id ?? null; + const effectiveInstallSoftware = + patchOption === undefined ? installSoftware : patchOption !== "manual"; + let effectiveSoftwareTitleId = softwareTitleId; + let effectiveSoftwareInstallerId = softwareInstallerId; + if (patchOption !== undefined) { + effectiveSoftwareTitleId = effectiveInstallSoftware + ? patchSoftwareTitleId + : null; + effectiveSoftwareInstallerId = + effectiveInstallSoftware && + policy.install_software?.software_title_id === patchSoftwareTitleId + ? softwareInstallerId + : null; + } const [scriptId, setScriptId] = useState<number | null>( policy.run_script?.id ?? null ); @@ -189,13 +221,14 @@ const PolicyAutomationsFields = forwardRef< const validate = (): IAutomationsErrors => { const newErrors: IAutomationsErrors = {}; - if (installSoftware && softwareTitleId === null) { + if (effectiveInstallSoftware && effectiveSoftwareTitleId === null) { newErrors.install_software = "Please select software to install."; } else if ( - installSoftware && - softwareTitleId !== null && + patchOption === undefined && + effectiveInstallSoftware && + effectiveSoftwareTitleId !== null && (selectedTitlePackages?.length ?? 0) > 0 && - softwareInstallerId === null + effectiveSoftwareInstallerId === null ) { // Only reachable when a custom title (with packages[]) is selected // but its packages haven't hydrated yet — the auto-select effect @@ -233,12 +266,21 @@ const PolicyAutomationsFields = forwardRef< setScriptId(id); if (id !== null) clearError("run_script"); }; + const handleToggleContinuous = (next: boolean) => { + setContinuousEnabled(next); + if (patchOption) { + onPatchOptionChange?.(next ? "force" : "manual"); + } + }; const canFetchTeamScopedLists = !isGlobalPolicy && teamIdForApi !== undefined; const { data: softwareTitlesData } = useSoftwareTitles({ fleetId: teamIdForApi ?? 0, - enabled: canFetchTeamScopedLists && installSoftware, + enabled: + canFetchTeamScopedLists && + effectiveInstallSoftware && + patchOption === undefined, }); const { data: scriptsData } = useScripts({ fleetId: teamIdForApi ?? 0, @@ -317,16 +359,18 @@ const PolicyAutomationsFields = forwardRef< const perPolicyDirty = !isGlobalPolicy && - (installSoftware !== initialInstallSoftware || - softwareTitleId !== + (effectiveInstallSoftware !== initialInstallSoftware || + effectiveSoftwareTitleId !== (policy.install_software?.software_title_id ?? null) || - softwareInstallerId !== + effectiveSoftwareInstallerId !== (policy.install_software?.software_installer_id ?? null) || runScript !== initialRunScript || scriptId !== (policy.run_script?.id ?? null) || calendarEvent !== initialCalendar || conditionalAccess !== initialConditionalAccess || - continuousEnabled !== initialContinuous); + effectiveContinuousEnabled !== initialContinuous || + (patchOption !== undefined && + patchWhenClosed !== initialPatchWhenClosed)); const webhookDirty = webhookOrTicketEnabled !== initialWebhookOrTicket; return { @@ -334,13 +378,14 @@ const PolicyAutomationsFields = forwardRef< isDirty: perPolicyDirty || webhookDirty, policyUpdate: perPolicyDirty ? { - software_title_id: installSoftware ? softwareTitleId : null, - // Send the pinned installer id when install-software is on; - // omit when unchecked so the backend can clear it. Null - // (title selected, no packages hydrated yet) is a validation - // error and shouldn't reach here. - software_installer_id: installSoftware - ? softwareInstallerId + software_title_id: effectiveInstallSoftware + ? effectiveSoftwareTitleId + : null, + // Send the pinned installer id when install-software is on. + // Null clears the automation or lets the backend select the + // Fleet-maintained app's installer when a Patch radio owns it. + software_installer_id: effectiveInstallSoftware + ? effectiveSoftwareInstallerId : null, script_id: runScript ? scriptId : null, // When the team has the feature disabled, the row is locked @@ -354,7 +399,11 @@ const PolicyAutomationsFields = forwardRef< ...(isConditionalAccessEnabledForTeam && { conditional_access_enabled: conditionalAccess, }), - continuous_automations_enabled: continuousEnabled, + continuous_automations_enabled: effectiveContinuousEnabled, + ...(patchOption !== undefined && + patchWhenClosed !== initialPatchWhenClosed && { + patch_when_closed: patchWhenClosed, + }), } : undefined, webhookOrTicketUpdate: webhookDirty @@ -387,47 +436,49 @@ const PolicyAutomationsFields = forwardRef< learnMoreUrl="https://fleetdm.com/learn-more-about/policy-automation-install-software" /> ), - checked: installSoftware, + checked: effectiveInstallSoftware, onToggle: handleToggleInstallSoftware, isDisabled: false, - picker: installSoftware ? ( - <div className={`${baseClass}__software-pickers`}> - <DropdownWrapper - name="software-title" - className={`${baseClass}__row-picker`} - isDisabled={gitOpsModeEnabled} - value={ - softwareOptions.find( - (o) => o.value === String(softwareTitleId ?? "") - ) ?? null - } - options={softwareOptions} - placeholder="Select software" - onChange={(opt: SingleValue<CustomOptionType>) => - handleSelectSoftware(opt ? Number(opt.value) : null) - } - /> - {/* Only surfaces for multi-package titles; first-added is - auto-selected above, so this is pin-adjustment. */} - {packageOptions.length > 1 && ( + isLocked: patchOption !== undefined, + picker: + effectiveInstallSoftware && patchOption === undefined ? ( + <div className={`${baseClass}__software-pickers`}> <DropdownWrapper - name="software-package" + name="software-title" className={`${baseClass}__row-picker`} isDisabled={gitOpsModeEnabled} value={ - packageOptions.find( - (o) => o.value === String(softwareInstallerId ?? "") + softwareOptions.find( + (o) => o.value === String(softwareTitleId ?? "") ) ?? null } - options={packageOptions} - placeholder="Select package" + options={softwareOptions} + placeholder="Select software" onChange={(opt: SingleValue<CustomOptionType>) => - handleSelectPackage(opt ? Number(opt.value) : null) + handleSelectSoftware(opt ? Number(opt.value) : null) } /> - )} - </div> - ) : undefined, + {/* Only surfaces for multi-package titles; first-added is + auto-selected above, so this is pin-adjustment. */} + {packageOptions.length > 1 && ( + <DropdownWrapper + name="software-package" + className={`${baseClass}__row-picker`} + isDisabled={gitOpsModeEnabled} + value={ + packageOptions.find( + (o) => o.value === String(softwareInstallerId ?? "") + ) ?? null + } + options={packageOptions} + placeholder="Select package" + onChange={(opt: SingleValue<CustomOptionType>) => + handleSelectPackage(opt ? Number(opt.value) : null) + } + /> + )} + </div> + ) : undefined, }, { key: "run_script", @@ -566,15 +617,20 @@ const PolicyAutomationsFields = forwardRef< </div> </div> - {!isGlobalPolicy && ( + {!isGlobalPolicy && patchOption !== "manual" && ( <div className={`${baseClass}__section`}> <GitOpsModeTooltipWrapper renderChildren={(disableChildren) => ( <Checkbox name="continuous-automations-enabled" - value={continuousEnabled} - disabled={disableChildren} - onChange={setContinuousEnabled} + value={effectiveContinuousEnabled} + disabled={disableChildren || patchWhenClosed} + onChange={handleToggleContinuous} + iconTooltipContent={ + patchWhenClosed + ? "Continuous automation can't be disabled when Patch when app is closed is selected." + : undefined + } helpText="If the automations do not resolve the policy, this could cause a retry loop." > <TooltipWrapper diff --git a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tests.tsx b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tests.tsx index b6697f7e1a..2e889f9e8b 100644 --- a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tests.tsx +++ b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tests.tsx @@ -11,6 +11,8 @@ import createMockConfig from "__mocks__/configMock"; import { createMockTeamSummary } from "__mocks__/teamMock"; import { ILabelSummary } from "interfaces/label"; +import teamPoliciesAPI from "services/entities/team_policies"; +import teamsAPI from "services/entities/teams"; import PolicyForm from "./PolicyForm"; const baseUrl = (path: string) => { @@ -41,6 +43,8 @@ const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => { }); describe("PolicyForm - component", () => { + afterEach(() => jest.restoreAllMocks()); + const defaultProps = { router: createMockRouter(), teamIdForApi: 3, @@ -102,6 +106,33 @@ describe("PolicyForm - component", () => { expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255"); }); + it("hides patch options in the free tier", () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: createMockUser(), + config: createMockConfig(), + isPremiumTier: false, + }, + }, + }); + + render( + <PolicyForm + {...defaultProps} + storedPolicy={createMockPolicy({ + type: "patch", + patch_software: { name: "Firefox", software_title_id: 42 }, + })} + /> + ); + + expect( + screen.queryByRole("radiogroup", { name: "Patch options" }) + ).not.toBeInTheDocument(); + }); + describe("in premium tier", () => { beforeEach(() => { mockServer.use(labelSummariesHandler); @@ -800,6 +831,138 @@ describe("PolicyForm - component", () => { expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument(); }); + it("selects Patch when app is closed from the stored policy flags", () => { + renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={{ + ...patchPolicy, + patch_when_closed: true, + continuous_automations_enabled: true, + }} + /> + ); + + expect( + screen.getByRole("radio", { name: "Patch when app is closed" }) + ).toBeChecked(); + }); + + it("selects Force patch from the stored policy flags", () => { + renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={{ + ...patchPolicy, + install_software: { + name: "Firefox", + software_title_id: 42, + }, + patch_when_closed: false, + continuous_automations_enabled: true, + }} + /> + ); + + expect( + screen.getByRole("radio", { name: "Force patch" }) + ).toBeChecked(); + }); + + it("selects Force patch for a migrated attached policy without continuous automation", () => { + renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={{ + ...patchPolicy, + install_software: { + name: "Firefox", + software_title_id: 42, + }, + patch_when_closed: false, + continuous_automations_enabled: false, + }} + /> + ); + + expect( + screen.getByRole("radio", { name: "Force patch" }) + ).toBeChecked(); + }); + + it("selects manual when continuous automation is on without install software", () => { + renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={{ + ...patchPolicy, + install_software: undefined, + patch_when_closed: false, + continuous_automations_enabled: true, + }} + /> + ); + + expect( + screen.getByRole("radio", { name: "End user initiated (manual)" }) + ).toBeChecked(); + }); + + it("saves the selected patch option before automation configuration loads", async () => { + jest + .spyOn(teamsAPI, "load") + .mockReturnValue(new Promise(() => undefined)); + const updatePolicySpy = jest + .spyOn(teamPoliciesAPI, "update") + .mockResolvedValue({} as never); + const teamPatchPolicy = { + ...patchPolicy, + team_id: 1, + patch_when_closed: false, + continuous_automations_enabled: false, + }; + const onUpdate = jest.fn().mockResolvedValue({}); + const { user } = renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={teamPatchPolicy} + onUpdate={onUpdate} + /> + ); + + await user.click(screen.getByRole("radio", { name: "Force patch" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(updatePolicySpy).toHaveBeenCalledWith(teamPatchPolicy.id, { + team_id: 1, + software_title_id: 42, + patch_when_closed: false, + continuous_automations_enabled: true, + }) + ); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it("selects End user initiated when both stored policy flags are false", () => { + renderPatchPolicy( + <PolicyForm + {...patchPolicyProps} + storedPolicy={{ + ...patchPolicy, + patch_when_closed: false, + continuous_automations_enabled: false, + }} + /> + ); + + expect( + screen.getByRole("radio", { + name: "End user initiated (manual)", + }) + ).toBeChecked(); + }); + it("submits only editable fields on save", async () => { const onUpdate = jest.fn(); renderPatchPolicy( @@ -820,14 +983,17 @@ describe("PolicyForm - component", () => { expect(payload).not.toHaveProperty("labels_include_any"); }); - it("shows 'Add automation' CTA when patch policy has no install_software", async () => { + it("hides the legacy Add automation CTA because the Patch radios own install automation", async () => { renderPatchPolicy(<PolicyForm {...patchPolicyProps} />); await waitFor(() => { expect( - screen.getByText(/Automatically patch Firefox/) + screen.getByRole("radio", { name: "End user initiated (manual)" }) ).toBeInTheDocument(); - expect(screen.getByText(/Add automation/)).toBeInTheDocument(); }); + expect( + screen.queryByText(/Automatically patch Firefox/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Add automation/)).not.toBeInTheDocument(); }); it("hides 'Add automation' CTA when automation already exists", async () => { diff --git a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx index b2329ee9f5..f1d1f42edd 100644 --- a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx @@ -55,6 +55,11 @@ import PolicyAutomationsFields, { IPolicyAutomationsPayload, } from "pages/policies/components/PolicyAutomationsFields"; import { PatchAutomationCta } from "pages/policies/components"; +import { + getPatchPolicyFlags, + PatchOption, + PatchOptionSelector, +} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector"; import { useUpdatePolicyAutomations, usePolicyLabelTargets, @@ -135,6 +140,27 @@ const PolicyForm = ({ const isPatchPolicy = storedPolicy?.type === "patch"; const [isAddingAutomation, setIsAddingAutomation] = useState(false); + const [patchOption, setPatchOption] = useState<PatchOption>("manual"); + const storedPatchPolicyId = storedPolicy?.id; + const storedPatchWhenClosed = storedPolicy?.patch_when_closed; + const storedInstallSoftwareId = + storedPolicy?.install_software?.software_title_id; + + useEffect(() => { + if (!isPatchPolicy || !storedPatchPolicyId) return; + let nextPatchOption: PatchOption = "manual"; + if (storedPatchWhenClosed) { + nextPatchOption = "closed"; + } else if (storedInstallSoftwareId) { + nextPatchOption = "force"; + } + setPatchOption(nextPatchOption); + }, [ + isPatchPolicy, + storedPatchPolicyId, + storedPatchWhenClosed, + storedInstallSoftwareId, + ]); // Note: The PolicyContext values should always be used for any mutable policy data such as query name // The storedPolicy prop should only be used to access immutable metadata such as author id @@ -409,6 +435,19 @@ const PolicyForm = ({ let automations: IPolicyAutomationsPayload | undefined; if (isEditMode) { automations = automationsRef.current?.getAutomationsPayload(); + if (!automations && isPremiumTier && isPatchPolicy) { + automations = { + isValid: true, + isDirty: true, + policyUpdate: { + software_title_id: + patchOption === "manual" + ? null + : storedPolicy?.patch_software?.software_title_id ?? null, + ...getPatchPolicyFlags(patchOption), + }, + }; + } if (automations && !automations.isValid) { return; } @@ -682,15 +721,31 @@ const PolicyForm = ({ disableOptions={gitOpsModeEnabled} /> )} + {isEditMode && isPremiumTier && isPatchPolicy && ( + <div className="form-field"> + <div className="form-field__label">Patch</div> + <GitOpsModeTooltipWrapper + renderChildren={(disableChildren) => ( + <PatchOptionSelector + patchOption={patchOption} + onSelectPatchOption={setPatchOption} + disabled={disableChildren} + /> + )} + /> + </div> + )} {isEditMode && !!storedPolicy && !!automationsConfig && ( <div className="form-field"> <div className="form-field__label">Automations</div> - <PatchAutomationCta - storedPolicy={storedPolicy} - canEditPolicy={isEditMode} - onAddAutomation={onAddPatchAutomation} - isAddingAutomation={isAddingAutomation} - /> + {!(isPremiumTier && isPatchPolicy) && ( + <PatchAutomationCta + storedPolicy={storedPolicy} + canEditPolicy={isEditMode} + onAddAutomation={onAddPatchAutomation} + isAddingAutomation={isAddingAutomation} + /> + )} <PolicyAutomationsFields key={storedPolicy.updated_at} ref={automationsRef} @@ -700,6 +755,10 @@ const PolicyForm = ({ automationsConfig={automationsConfig} globalConfig={config ?? undefined} fleetName={automationsFleetName} + patchOption={ + isPremiumTier && isPatchPolicy ? patchOption : undefined + } + onPatchOptionChange={setPatchOption} /> </div> )} diff --git a/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts b/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts index 3258837094..7daeb4c851 100644 --- a/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts +++ b/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts @@ -18,6 +18,7 @@ export type IPolicyAutomationUpdate = Pick< | "calendar_events_enabled" | "conditional_access_enabled" | "continuous_automations_enabled" + | "patch_when_closed" >; export interface IUpdatePolicyAutomationsVars { diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index bb66705b8d..65dd2de49f 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -277,7 +277,8 @@ const handleDisplayNameForm = ( const handleEditPackageForm = ( data: IEditPackageFormData, formData: FormData, - orignalPackage: ISoftwarePackage + orignalPackage: ISoftwarePackage, + omitPreInstallQuery = false ) => { data.software && formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); @@ -286,10 +287,12 @@ const handleEditPackageForm = ( "install_script", encodeScriptBase64(data.installScript) || "" ); - formData.append( - "pre_install_query", - encodeScriptBase64(data.preInstallQuery || "") || "" - ); + if (!omitPreInstallQuery) { + formData.append( + "pre_install_query", + encodeScriptBase64(data.preInstallQuery || "") || "" + ); + } formData.append( "post_install_script", encodeScriptBase64(data.postInstallScript || "") || "" @@ -609,6 +612,7 @@ export default { timeout, onUploadProgress, signal, + omitPreInstallQuery, }: { data: | IEditPackageFormData @@ -624,6 +628,7 @@ export default { timeout?: number; onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; signal?: AbortSignal; + omitPreInstallQuery?: boolean; }) => { const { EDIT_SOFTWARE_PACKAGE } = endpoints; const formData = new FormData(); @@ -650,7 +655,8 @@ export default { handleEditPackageForm( data as IEditPackageFormData, formData, - orignalPackage + orignalPackage, + omitPreInstallQuery ); } @@ -847,7 +853,7 @@ export default { post_install_script: encodeScriptBase64(formData.postInstallScript), uninstall_script: encodeScriptBase64(formData.uninstallScript), self_service: formData.selfService, - automatic_install: formData.automaticInstall, + automatic_install: formData.forceInstall, categories: formData.categories, }; diff --git a/frontend/services/entities/team_policies.tests.ts b/frontend/services/entities/team_policies.tests.ts new file mode 100644 index 0000000000..22fe8826a6 --- /dev/null +++ b/frontend/services/entities/team_policies.tests.ts @@ -0,0 +1,53 @@ +import sendRequest from "services"; + +import teamPoliciesAPI from "./team_policies"; + +jest.mock("services", () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockSendRequest = sendRequest as jest.MockedFunction<typeof sendRequest>; + +describe("teamPoliciesAPI patch policy flags", () => { + beforeEach(() => { + mockSendRequest.mockReset(); + mockSendRequest.mockResolvedValue({}); + }); + + it("forwards both flags when creating a patch policy", async () => { + await teamPoliciesAPI.create({ + team_id: 1, + type: "patch", + patch_software_title_id: 10, + patch_when_closed: true, + continuous_automations_enabled: true, + }); + + expect(mockSendRequest).toHaveBeenCalledWith( + "POST", + expect.stringContaining("/1/policies"), + expect.objectContaining({ + patch_when_closed: true, + continuous_automations_enabled: true, + }) + ); + }); + + it("retains false flag values when updating a patch policy", async () => { + await teamPoliciesAPI.update(22, { + team_id: 1, + patch_when_closed: false, + continuous_automations_enabled: false, + }); + + expect(mockSendRequest).toHaveBeenCalledWith( + "PATCH", + expect.stringContaining("/1/policies/22"), + expect.objectContaining({ + patch_when_closed: false, + continuous_automations_enabled: false, + }) + ); + }); +}); diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 38a5f15ead..0f6c75cd55 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -88,7 +88,8 @@ export default { labels_exclude_all, type, patch_software_title_id, - // note absence of automations-related fields, which are only set by the UI via update + continuous_automations_enabled, + patch_when_closed, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies`; @@ -107,6 +108,8 @@ export default { labels_exclude_all, type, patch_software_title_id, + continuous_automations_enabled, + patch_when_closed, }); }, // TODO - response type Promise<IPolicy> @@ -123,6 +126,7 @@ export default { calendar_events_enabled, conditional_access_enabled, continuous_automations_enabled, + patch_when_closed, software_title_id, software_installer_id, script_id, @@ -144,6 +148,7 @@ export default { calendar_events_enabled, conditional_access_enabled, continuous_automations_enabled, + patch_when_closed, software_title_id, software_installer_id, script_id, diff --git a/frontend/test/handlers/software-handlers.ts b/frontend/test/handlers/software-handlers.ts index 764e4ebb75..7f0d47c19f 100644 --- a/frontend/test/handlers/software-handlers.ts +++ b/frontend/test/handlers/software-handlers.ts @@ -113,6 +113,21 @@ export const getSoftwareInstallHandlerOnlyPreInstallOutput = http.get( } ); +export const getSoftwareInstallHandlerAppOpen = http.get( + baseUrl("/software/install/:install_uuid/results"), + ({ params }) => { + return HttpResponse.json({ + results: createMockSoftwareInstallResult({ + install_uuid: params.install_uuid as string, + status: "failed_install", + output: "", + post_install_script_output: "", + pre_install_query_output: "The app was open\nInstall stopped", + }), + }); + } +); + // Installed, with SHA-256 hash export const getSoftwareInstallHandlerWithHash = http.get( baseUrl("/software/install/:install_uuid/results"), diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 1450ab9cbd..8cabb3aba5 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -2994,7 +2994,7 @@ func (ds *Datastore) getPatchPolicyInstaller(ctx context.Context, teamID uint, t } func (ds *Datastore) GetPatchPolicy(ctx context.Context, teamID *uint, titleID uint) (*fleet.PatchPolicyData, error) { - query := `SELECT id, name, patch_when_closed FROM policies WHERE team_id = ? AND patch_software_title_id = ?` + query := `SELECT id, name, patch_when_closed, continuous_automations_enabled FROM policies WHERE team_id = ? AND patch_software_title_id = ?` var policy fleet.PatchPolicyData err := sqlx.GetContext(ctx, ds.reader(ctx), &policy, query, ptr.ValOrZero(teamID), titleID) diff --git a/server/fleet/maintained_apps.go b/server/fleet/maintained_apps.go index 207d90a712..2e86f9db5c 100644 --- a/server/fleet/maintained_apps.go +++ b/server/fleet/maintained_apps.go @@ -15,7 +15,7 @@ type MaintainedApp struct { UniqueIdentifier string `json:"-" db:"unique_identifier"` InstallScript string `json:"install_script,omitempty" db:"install_script"` UninstallScript string `json:"uninstall_script,omitempty" db:"uninstall_script"` - AutomaticInstallQuery string `json:"-" db:"pre_install_query"` + AutomaticInstallQuery string `json:"automatic_install_query,omitempty" db:"pre_install_query"` Categories []string `json:"categories"` UpgradeCode string `json:"upgrade_code,omitempty" db:"upgrade_code"` PatchQuery string `json:"-" db:"patch_query"` diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index bb3ca55720..caab3cb7d5 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -889,9 +889,10 @@ type AutomaticInstallPolicy struct { } type PatchPolicyData struct { - ID uint `json:"id" db:"id"` - Name string `json:"name" db:"name"` - PatchWhenClosed bool `json:"patch_when_closed" db:"patch_when_closed"` + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + PatchWhenClosed bool `json:"patch_when_closed" db:"patch_when_closed"` + ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"` } // SoftwarePackageOrApp provides information about a software installer diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index b8252e6418..6e4f446966 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -21809,15 +21809,16 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { _, err = maintained_apps.Hydrate(ctx, dbAppRecord, "", nil, nil) require.NoError(t, err) dbAppResponse := fleet.MaintainedApp{ - ID: dbAppRecord.ID, - Name: dbAppRecord.Name, - Slug: dbAppRecord.Slug, - Version: dbAppRecord.Version, - Platform: dbAppRecord.Platform, - InstallerURL: dbAppRecord.InstallerURL, - InstallScript: dbAppRecord.InstallScript, - UninstallScript: dbAppRecord.UninstallScript, - Categories: []string{"Productivity"}, + ID: dbAppRecord.ID, + Name: dbAppRecord.Name, + Slug: dbAppRecord.Slug, + Version: dbAppRecord.Version, + Platform: dbAppRecord.Platform, + InstallerURL: dbAppRecord.InstallerURL, + InstallScript: dbAppRecord.InstallScript, + UninstallScript: dbAppRecord.UninstallScript, + AutomaticInstallQuery: dbAppRecord.AutomaticInstallQuery, + Categories: []string{"Productivity"}, } require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallerURL) require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallScript)