From 56b34eb29f03ae605dfbd742498854562cbf38de Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 14 May 2025 11:42:37 -0400 Subject: [PATCH] Fleet UI: Allow self-service uninstallations of FMA and custom packages (#29055) --- .../SoftwareInstallerCard.tsx | 2 +- .../InstallStatusCell.tests.tsx | 2 +- .../InstallStatusCell/InstallStatusCell.tsx | 13 +- .../SelfService/SelfService.tests.tsx | 82 +++++-- .../Software/SelfService/SelfService.tsx | 131 +++++++---- .../SelfService/SelfServiceTableConfig.tsx | 215 +++++++++++++----- .../UninstallSoftwareModal.tests.tsx | 78 +++++++ .../UninstallSoftwareModal.tsx | 93 ++++++++ .../UninstallSoftwareModal/_styles.scss | 3 + .../UninstallSoftwareModal/index.ts | 1 + .../cards/Software/SelfService/_styles.scss | 14 ++ frontend/services/entities/device_user.ts | 11 + frontend/utilities/endpoints.ts | 2 + 13 files changed, 529 insertions(+), 118 deletions(-) create mode 100644 frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tests.tsx create mode 100644 frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tsx create mode 100644 frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/index.ts diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index ef34a8aeec..ebe66dbe7f 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -58,7 +58,7 @@ const STATUS_DISPLAY_OPTIONS: Record< iconName: "success", tooltip: ( <> - Software is installed on these hosts (install script finished + Software was installed on these hosts (install script finished
with exit code 0). Currently, if the software is uninstalled, the
diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx index daa311abdb..0d9472bb78 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx @@ -28,7 +28,7 @@ describe("InstallStatusCell - component", () => { expect(screen.getByText("Installed")).toBeInTheDocument(); await user.hover(screen.getByText("Installed")); - expect(screen.getByText(/Software is installed/i)).toBeInTheDocument(); + expect(screen.getByText(/Software was installed/i)).toBeInTheDocument(); }); it("renders 'Installing (pending)' status with tooltip", async () => { diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index b6c74df851..85050cb992 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -46,7 +46,7 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< ) : ( <> - Software is installed (install + Software was installed (install
script finished with exit code 0). @@ -101,13 +101,14 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< tooltip: ({ softwareName, isAppStoreApp }) => isAppStoreApp ? ( <> - App Store app can be installed on the host. Select{" "} - Actions {">"} Install to install. + App Store app can be installed on the host.
+ Select Actions {">"} Install to install. ) : ( <> {softwareName ? {softwareName} : "Software"} can be installed - on the host. Select Actions > Install to install. + on the host. +
Select Actions > Install to install. ), }, @@ -117,7 +118,9 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< tooltip: ({ softwareName }) => ( <> {softwareName ? {softwareName} : "Software"} can be installed on - the host. {SELF_SERVICE_TOOLTIP} + the host. +
+ {SELF_SERVICE_TOOLTIP} ), }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index b5af428414..5f25e904e5 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -70,7 +70,7 @@ describe("SelfService", () => { ); }); - it("renders 'Reinstall' action button with 'Installed' status", async () => { + it("renders installed status and 'Reinstall' and 'Uninstall' action buttons with 'installed'", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ @@ -117,12 +117,11 @@ describe("SelfService", () => { screen.getByTestId("self-service-table__status--test") ).toHaveTextContent("Installed"); - expect( - screen.getByTestId("self-service-table__action-button--test") - ).toHaveTextContent("Reinstall"); + expect(screen.getByRole("button", { name: "Reinstall" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled(); }); - it("renders 'Retry' action button with 'failed_install' status", async () => { + it("renders failed status and 'Retry' and 'Uninstall' action buttons with 'failed_install'", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ @@ -144,12 +143,39 @@ describe("SelfService", () => { screen.getByTestId("self-service-table__status--test") ).toHaveTextContent("Failed"); - expect( - screen.getByTestId("self-service-table__action-button--test") - ).toHaveTextContent("Retry"); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled(); }); - it("renders 'Install' action button with no status", async () => { + it("renders failed status and 'Install' and 'Retry uninstall' action buttons with 'failed_uninstall' status", async () => { + mockServer.use( + customDeviceSoftwareHandler({ + software: [ + createMockDeviceSoftware({ + name: "test-software", + status: "failed_uninstall", + }), + ], + }) + ); + + const render = createCustomRenderer({ withBackendMock: true }); + render(); + + // waiting for the device software data to render + await screen.findByText("test-software"); + + expect( + screen.getByTestId("self-service-table__status--test") + ).toHaveTextContent("Failed"); + + expect(screen.getByRole("button", { name: "Install" })).toBeEnabled(); + expect( + screen.getByRole("button", { name: "Retry uninstall" }) + ).toBeEnabled(); + }); + + it("renders no status and 'Install' and 'Uninstall' action buttons with no API status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ @@ -171,12 +197,11 @@ describe("SelfService", () => { screen.queryByTestId("self-service-table__status--test") ).not.toBeInTheDocument(); - expect( - screen.getByTestId("self-service-table__action-button--test") - ).toHaveTextContent("Install"); + expect(screen.getByRole("button", { name: "Install" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Uninstall" })).toBeEnabled(); }); - it("renders no action button with 'pending_install' status", async () => { + it("renders installing status and disables action buttons with 'pending_install'", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ @@ -196,10 +221,35 @@ describe("SelfService", () => { expect( screen.getByTestId("self-service-table__status--test") - ).toHaveTextContent(/Installing.../i); + ).toHaveTextContent("Installing..."); + + expect(screen.getByRole("button", { name: "Install" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Uninstall" })).toBeDisabled(); + }); + + it("renders uninstalling status and disables 'Reinstall' and 'Uninstall' action buttons with 'pending_uninstall'", async () => { + mockServer.use( + customDeviceSoftwareHandler({ + software: [ + createMockDeviceSoftware({ + name: "test-software", + status: "pending_uninstall", + }), + ], + }) + ); + + const render = createCustomRenderer({ withBackendMock: true }); + render(); + + // waiting for the device software data to render + await screen.findAllByText("test-software"); expect( - screen.queryByTestId("self-service-table__action-button--test") - ).not.toBeInTheDocument(); + screen.getByTestId("self-service-table__status--test") + ).toHaveTextContent("Uninstalling..."); + + expect(screen.getByRole("button", { name: "Reinstall" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Uninstall" })).toBeDisabled(); }); }); diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index d5dfc23656..21c994285c 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -19,6 +19,7 @@ import deviceApi, { import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import { getPathWithQueryParams } from "utilities/url"; +import { getExtensionFromFileName } from "utilities/file/fileUtils"; import { SingleValue } from "react-select-5"; import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; @@ -34,6 +35,7 @@ import SearchField from "components/forms/fields/SearchField"; import DropdownWrapper from "components/forms/fields/DropdownWrapper"; import Pagination from "components/Pagination"; +import UninstallSoftwareModal from "./UninstallSoftwareModal"; import { InstallOrCommandUuid, generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig, @@ -82,6 +84,16 @@ const SoftwareSelfService = ({ const [selfServiceData, setSelfServiceData] = useState< IGetDeviceSoftwareResponse | undefined >(undefined); + const [showUninstallSoftwareModal, setShowUninstallSoftwareModal] = useState( + false + ); + + const selectedSoftware = useRef<{ + softwareId: number; + softwareName: string; + softwareInstallerType?: string; + version: string; + } | null>(null); const pendingSoftwareSetRef = useRef>(new Set()); // Track for polling const pollingTimeoutIdRef = useRef(null); @@ -113,8 +125,8 @@ const SoftwareSelfService = ({ }, }); - // Poll for pending installs - const { refetch: refetchForPendingInstalls } = useQuery< + // Poll for pending installs/uninstalls + const { refetch: refetchForPendingInstallsOrUninstalls } = useQuery< IGetDeviceSoftwareResponse, AxiosError >( @@ -126,7 +138,11 @@ const SoftwareSelfService = ({ // Get the set of pending software IDs const newPendingSet = new Set( response.software - .filter((software) => software.status === "pending_install") + .filter( + (software) => + software.status === "pending_install" || + software.status === "pending_uninstall" + ) .map((software) => String(software.id)) ); @@ -149,10 +165,10 @@ const SoftwareSelfService = ({ clearTimeout(pollingTimeoutIdRef.current); } pollingTimeoutIdRef.current = setTimeout(() => { - refetchForPendingInstalls(); + refetchForPendingInstallsOrUninstalls(); }, 5000); } else { - // No pending installs, stop polling and refresh data + // No pending installs nor pending uninstalls, stop polling and refresh data pendingSoftwareSetRef.current = new Set(); if (pollingTimeoutIdRef.current) { clearTimeout(pollingTimeoutIdRef.current); @@ -171,7 +187,7 @@ const SoftwareSelfService = ({ } ); - const startPollingForPendingInstalls = useCallback( + const startPollingForPendingInstallsOrUninstalls = useCallback( (pendingIds: string[]) => { const newSet = new Set(pendingIds); const setsAreEqual = @@ -184,10 +200,10 @@ const SoftwareSelfService = ({ if (pollingTimeoutIdRef.current) { clearTimeout(pollingTimeoutIdRef.current); } - refetchForPendingInstalls(); // Starts polling for pending installs + refetchForPendingInstallsOrUninstalls(); // Starts polling for pending installs } }, - [refetchForPendingInstalls] + [refetchForPendingInstallsOrUninstalls] ); // Cleanup on unmount @@ -201,20 +217,22 @@ const SoftwareSelfService = ({ }; }, []); - // On initial load or data change, check for pending installs + // On initial load or data change, check for pending installs/uninstalls useEffect(() => { const pendingSoftware = selfServiceData?.software.filter( - (software) => software.status === "pending_install" + (software) => + software.status === "pending_install" || + software.status === "pending_uninstall" ); const pendingIds = pendingSoftware?.map((s) => String(s.id)) ?? []; if (pendingIds.length > 0) { - startPollingForPendingInstalls(pendingIds); + startPollingForPendingInstallsOrUninstalls(pendingIds); } - }, [selfServiceData, startPollingForPendingInstalls]); + }, [selfServiceData, startPollingForPendingInstallsOrUninstalls]); - const onInstall = useCallback(() => { - refetchForPendingInstalls(); - }, [refetchForPendingInstalls]); + const onInstallOrUninstall = useCallback(() => { + refetchForPendingInstallsOrUninstalls(); + }, [refetchForPendingInstallsOrUninstalls]); const onSearchQueryChange = (value: string) => { router.push( @@ -238,6 +256,17 @@ const SoftwareSelfService = ({ ); }; + const onExitUninstallSoftwareModal = () => { + selectedSoftware.current = null; + setShowUninstallSoftwareModal(false); + }; + + const onSuccessUninstallSoftwareModal = () => { + selectedSoftware.current = null; + setShowUninstallSoftwareModal(false); + onInstallOrUninstall; + }; + const onNextPage = useCallback(() => { router.push( getPathWithQueryParams(pathname, { @@ -273,10 +302,21 @@ const SoftwareSelfService = ({ const tableConfig = useMemo(() => { return generateDeviceSoftwareTableConfig({ deviceToken, - onInstall, + onInstall: onInstallOrUninstall, onShowInstallerDetails, + onClickUninstallAction: (software) => { + selectedSoftware.current = { + softwareId: software.id, + softwareName: software.name, + softwareInstallerType: getExtensionFromFileName( + software.software_package?.name || "" + ), + version: software.software_package?.version || "", + }; + setShowUninstallSoftwareModal(true); + }, }); - }, [router]); + }, [deviceToken, onInstallOrUninstall, onShowInstallerDetails]); const renderSelfServiceCard = () => { const renderHeaderFilters = () => ( @@ -409,28 +449,41 @@ const SoftwareSelfService = ({ }; return ( - - - Install organization-approved apps provided by your IT department.{" "} - {contactUrl && ( - - If you need help,{" "} - - - )} - - } - /> - {renderSelfServiceCard()} - + <> + + + Install organization-approved apps provided by your IT department.{" "} + {contactUrl && ( + + If you need help,{" "} + + + )} + + } + /> + {renderSelfServiceCard()} + + {showUninstallSoftwareModal && selectedSoftware.current && ( + + )} + ); }; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx index 0169638bfc..ec0c7ee854 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceTableConfig.tsx @@ -1,4 +1,10 @@ -import React, { useRef, useEffect, useCallback, useContext } from "react"; +import React, { + useRef, + useEffect, + useState, + useCallback, + useContext, +} from "react"; import { CellProps, Column } from "react-table"; import { @@ -19,6 +25,7 @@ import { dateAgo } from "utilities/date_format"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import Spinner from "components/Spinner"; import Icon from "components/Icon"; +import { IconNames } from "components/icons"; import Button from "components/buttons/Button"; import TooltipWrapper from "components/TooltipWrapper"; @@ -35,18 +42,12 @@ type IVulnerabilitiesCellProps = IInstalledVersionsCellProps; const baseClass = "self-service-table"; -const STATUS_CONFIG: Record< - Exclude< - SoftwareInstallStatus, - "pending_uninstall" | "failed_uninstall" | "uninstalled" - >, - IStatusDisplayConfig -> = { +const STATUS_CONFIG: Record = { installed: { iconName: "success", displayText: "Installed", tooltip: ({ lastInstalledAt = null }) => { - return `Software is installed${ + return `Software was installed${ lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : "" }.`; }, @@ -67,6 +68,31 @@ const STATUS_CONFIG: Record< ), }, + uninstalled: { + iconName: "success", + displayText: "Uninstalled", + tooltip: ({ lastInstalledAt = null }) => { + return `Software uninstalled${ + lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : "" + }.`; + }, + }, + pending_uninstall: { + iconName: "pending-outline", + displayText: "Uninstalling...", + tooltip: () => "Fleet is uninstalling software.", + }, + failed_uninstall: { + iconName: "error", + displayText: "Failed (uninstall)", + tooltip: ({ lastInstalledAt = null }) => ( + <> + Software failed to uninstall + {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} + Retry to uninstall again, or contact your IT department. + + ), + }, }; interface CommandUuid { @@ -140,60 +166,117 @@ const InstallerStatus = ({ ); }; -interface IInstallerStatusActionProps { +interface IInstallerStatusActionsProps { deviceToken: string; software: IHostSoftware; - onInstall: () => void; + onInstallOrUninstall: () => void; + onClickUninstallAction: (software: IHostSoftware) => void; +} + +interface DisplayActionItems { + install: { + text: string; + icon: IconNames; + }; + uninstall: { + text: string; + icon: IconNames; + }; } const getInstallButtonText = (status: SoftwareInstallStatus | null) => { switch (status) { - case null: - return "Install"; case "failed_install": return "Retry"; case "installed": + case "uninstalled": + case "pending_uninstall": return "Reinstall"; default: - return ""; + // including null + return "Install"; } }; const getInstallButtonIcon = (status: SoftwareInstallStatus | null) => { switch (status) { - case null: - return "install"; case "failed_install": - return "refresh"; case "installed": + case "uninstalled": + case "pending_uninstall": + case "failed_uninstall": return "refresh"; default: - return undefined; + // including null + return "install"; + } +}; + +const getUninstallButtonText = (status: SoftwareInstallStatus | null) => { + switch (status) { + case "failed_uninstall": + return "Retry uninstall"; + default: + // including null, "installed", "pending_install", "pending_uninstalled", "failed_install" + return "Uninstall"; + } +}; + +const getUninstallButtonIcon = (status: SoftwareInstallStatus | null) => { + switch (status) { + case "failed_uninstall": + return "refresh"; + default: + // including null, "installed", "pending_install", "pending_uninstalled", "failed_install" + return "trash"; } }; const InstallerStatusAction = ({ deviceToken, software: { id, status, software_package, app_store_app }, - onInstall, -}: IInstallerStatusActionProps) => { + onInstallOrUninstall, + onClickUninstallAction, +}: IInstallerStatusActionsProps) => { const { renderFlash } = useContext(NotificationContext); - // TODO: update this if/when we support self-service app store apps - const last_install = - software_package?.last_install ?? app_store_app?.last_install ?? null; + // displayActionItems is used to track the display text and icons of the install and uninstall button + const [ + displayActionItems, + setDisplayActionItems, + ] = useState({ + install: { + text: getInstallButtonText(status), + icon: getInstallButtonIcon(status), + }, + uninstall: { + text: getUninstallButtonText(status), + icon: getUninstallButtonIcon(status), + }, + }); - // localStatus is used to track the status of the any user-initiated install action - const [localStatus, setLocalStatus] = React.useState< - SoftwareInstallStatus | undefined - >(undefined); + useEffect(() => { + // We update the text/icon only when we see a change to a non-pending status + // Pending statuses keep the original text shown (e.g. "Retry" text on failed + // install shouldn't change to "Install" text because it was clicked and went + // pending. Once the status is no longer pending, like 'installed' the + // text will update to "Reinstall") + if (status !== "pending_install" && status !== "pending_uninstall") { + setDisplayActionItems({ + install: { + text: getInstallButtonText(status), + icon: getInstallButtonIcon(status), + }, + uninstall: { + text: getUninstallButtonText(status), + icon: getUninstallButtonIcon(status), + }, + }); + } + }, [status]); - const installButtonText = getInstallButtonText(status); - const installButtonIcon = getInstallButtonIcon(status); - - // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we - // set this to null, which tells the tooltip to omit the parenthetical date - const lastInstall = localStatus === "failed_install" ? null : last_install; + const isAppStoreApp = !!app_store_app; + const canUninstallSoftware = !isAppStoreApp && !!software_package; const isMountedRef = useRef(false); useEffect(() => { @@ -203,45 +286,60 @@ const InstallerStatusAction = ({ }; }, []); - const onClick = useCallback(async () => { - setLocalStatus("pending_install"); + const onClickInstallAction = useCallback(async () => { try { await deviceApi.installSelfServiceSoftware(deviceToken, id); if (isMountedRef.current) { - onInstall(); + onInstallOrUninstall(); } } catch (error) { renderFlash("error", "Couldn't install. Please try again."); - if (isMountedRef.current) { - setLocalStatus("failed_install"); - } } - }, [deviceToken, id, onInstall, renderFlash]); + }, [deviceToken, id, onInstallOrUninstall, renderFlash]); return ( -
+
- {installButtonText ? ( + +
+
+ {canUninstallSoftware && ( - ) : ( - DEFAULT_EMPTY_CELL_VALUE )}
@@ -258,6 +356,7 @@ interface ISelfServiceTableHeaders { deviceToken: string; onInstall: () => void; onShowInstallerDetails: (uuid?: InstallOrCommandUuid) => void; + onClickUninstallAction: (software: IHostSoftware) => void; } // NOTE: cellProps come from react-table @@ -266,6 +365,7 @@ export const generateSoftwareTableHeaders = ({ deviceToken, onInstall, onShowInstallerDetails, + onClickUninstallAction, }: ISelfServiceTableHeaders): ISoftwareTableConfig[] => { const tableHeaders: ISoftwareTableConfig[] = [ { @@ -320,7 +420,10 @@ export const generateSoftwareTableHeaders = ({ + onClickUninstallAction(cellProps.row.original) + } /> ); }, diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tests.tsx new file mode 100644 index 0000000000..708bd090ff --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tests.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { noop } from "lodash"; +import UninstallSoftwareModal from "./UninstallSoftwareModal"; + +describe("UninstallSoftwareModal", () => { + it("renders the generic uninstall message with software name", () => { + render( + + ); + + expect( + screen.getByText( + /Uninstalling this software will remove it and may remove Slack data from your device/i + ) + ).toBeVisible(); + expect(screen.getByRole("button", { name: /Uninstall/i })).toBeVisible(); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeVisible(); + }); + + it("renders the generic uninstall message with default software name", () => { + render( + + ); + + expect( + screen.getByText(/Uninstalling this software will remove it/i) + ).toBeVisible(); + }); + + it("renders the MSI-specific message when installer type is 'msi'", () => { + render( + + ); + + expect( + screen.getByText(/this will only uninstall version 5.0.0/i) + ).toBeVisible(); + expect(screen.getByRole("link", { name: /click here/i })).toBeVisible(); + }); + + it("does not render the MSI-specific message for non-msi installer types", () => { + render( + + ); + + expect( + screen.queryByText(/this will only uninstall version/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tsx new file mode 100644 index 0000000000..e3398a8f88 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/UninstallSoftwareModal.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useContext, useState } from "react"; + +import deviceUserAPI from "services/entities/device_user"; +import { NotificationContext } from "context/notification"; +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import CustomLink from "components/CustomLink"; + +const baseClass = "uninstall-software-modal"; + +interface IUninstallSoftwareModalProps { + softwareId: number; + softwareName?: string; + softwareInstallerType?: string; + version?: string; + token: string; + onExit: () => void; + onSuccess: () => void; +} + +const UninstallSoftwareModal = ({ + softwareId, + softwareName, + softwareInstallerType, + version, + token, + onExit, + onSuccess, +}: IUninstallSoftwareModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isUninstalling, setIsUninstalling] = useState(false); + + const onUninstallSoftware = useCallback(async () => { + setIsUninstalling(true); + try { + await deviceUserAPI.uninstallSelfServiceSoftware(token, softwareId); + // TODO: Change this toast message or hide it all together? + // renderFlash("success", "Software uninstalled successfully!"); + onSuccess(); + } catch (error) { + renderFlash("error", "Couldn't uninstall. Please try again."); + } + setIsUninstalling(false); + onExit(); + }, [softwareId, renderFlash, onSuccess, onExit]); + + const displaySoftwareName = softwareName || "software"; + const msiInstaller = softwareInstallerType === "msi"; + + return ( + + <> +

+ Uninstalling this software will remove it and may remove{" "} + {softwareName} data from your device. You can always reinstall it + again later. +

+ {msiInstaller && ( +

+ By default, this will only uninstall version {version}. To learn how + to uninstall other versions,{" "} + +

+ )} +
+ + +
+ +
+ ); +}; + +export default UninstallSoftwareModal; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/_styles.scss new file mode 100644 index 0000000000..40f0f497e2 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/_styles.scss @@ -0,0 +1,3 @@ +.uninstall-software-modal { + overflow-wrap: anywhere; // Prevent long software name overflow +} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/index.ts new file mode 100644 index 0000000000..ee67a882d6 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/UninstallSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./UninstallSoftwareModal"; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss index 6b81e8fb19..f185897d12 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss @@ -33,9 +33,23 @@ gap: $pad-small; } + .self-service-table__status-content { + min-width: 100px; // No table layout shift when changed to pending status + } + .self-service-table__item-status-button { height: auto; } + + .self-service-table__item-actions { + display: flex; + flex-direction: row; + gap: $pad-large; + } + + .self-service-table__item-action { + min-width: 82px; // Second action buttons align between rows + } } .categories-menu { diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 0e5b20cbef..b798311256 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -90,6 +90,17 @@ export default { return sendRequest("POST", path); }, + uninstallSelfServiceSoftware: ( + deviceToken: string, + softwareTitleId: number + ) => { + const { DEVICE_SOFTWARE_UNINSTALL } = endpoints; + return sendRequest( + "POST", + DEVICE_SOFTWARE_UNINSTALL(deviceToken, softwareTitleId) + ); + }, + /** Gets more info on FMA/custom package install for device user */ getSoftwareInstallResult: (deviceToken: string, uuid: string) => { const { DEVICE_SOFTWARE_INSTALL_RESULTS } = endpoints; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 053070da6e..460de70148 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -41,6 +41,8 @@ export default { `/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`, DEVICE_SOFTWARE_INSTALL_RESULTS: (token: string, uuid: string) => `/${API_VERSION}/fleet/device/${token}/software/install/${uuid}/results`, + DEVICE_SOFTWARE_UNINSTALL: (token: string, softwareTitleId: number) => + `/${API_VERSION}/fleet/device/${token}/software/uninstall/${softwareTitleId}`, DEVICE_VPP_COMMAND_RESULTS: (token: string, uuid: string) => `/${API_VERSION}/fleet/device/${token}/software/commands/${uuid}/results`, DEVICE_USER_MDM_ENROLLMENT_PROFILE: (token: string): string => {