Fleet UI: Allow self-service uninstallations of FMA and custom packages (#29055)

This commit is contained in:
RachelElysia 2025-05-14 11:42:37 -04:00 committed by GitHub
parent 714337163e
commit 56b34eb29f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 529 additions and 118 deletions

View file

@ -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
<br />
with exit code 0). Currently, if the software is uninstalled, the
<br />

View file

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

View file

@ -46,7 +46,7 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
</>
) : (
<>
Software is installed (install
Software was installed (install
<br />
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{" "}
<b>Actions {">"} Install</b> to install.
App Store app can be installed on the host. <br />
Select <b>Actions {">"} Install</b> to install.
</>
) : (
<>
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed
on the host. Select <b>Actions &gt; Install</b> to install.
on the host.
<br /> Select <b>Actions &gt; Install</b> to install.
</>
),
},
@ -117,7 +118,9 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
tooltip: ({ softwareName }) => (
<>
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed on
the host. {SELF_SERVICE_TOOLTIP}
the host.
<br />
{SELF_SERVICE_TOOLTIP}
</>
),
},

View file

@ -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(<SelfService {...TEST_PROPS} />);
// 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(<SelfService {...TEST_PROPS} />);
// 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();
});
});

View file

@ -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<Set<string>>(new Set()); // Track for polling
const pollingTimeoutIdRef = useRef<NodeJS.Timeout | null>(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 (
<Card
className={baseClass}
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
>
<CardHeader
header="Self-service"
subheader={
<>
Install organization-approved apps provided by your IT department.{" "}
{contactUrl && (
<span>
If you need help,{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</span>
)}
</>
}
/>
{renderSelfServiceCard()}
</Card>
<>
<Card
className={baseClass}
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
>
<CardHeader
header="Self-service"
subheader={
<>
Install organization-approved apps provided by your IT department.{" "}
{contactUrl && (
<span>
If you need help,{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</span>
)}
</>
}
/>
{renderSelfServiceCard()}
</Card>
{showUninstallSoftwareModal && selectedSoftware.current && (
<UninstallSoftwareModal
softwareId={selectedSoftware.current.softwareId}
softwareName={selectedSoftware.current.softwareName}
softwareInstallerType={selectedSoftware.current.softwareInstallerType}
version={selectedSoftware.current.version}
token={deviceToken}
onExit={onExitUninstallSoftwareModal}
onSuccess={onSuccessUninstallSoftwareModal}
/>
)}
</>
);
};

View file

@ -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<SoftwareInstallStatus, IStatusDisplayConfig> = {
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{" "}
<b>Retry</b> 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<DisplayActionItems>({
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 (
<div className={`${baseClass}__item-status-action`}>
<div className={`${baseClass}__item-actions`}>
<div className={`${baseClass}__item-action`}>
{installButtonText ? (
<Button
variant="text-icon"
type="button"
className={`${baseClass}__item-action-button`}
onClick={onClickInstallAction}
disabled={
status === "pending_install" || status === "pending_uninstall"
}
>
<Icon
name={displayActionItems.install.icon}
color="core-fleet-blue"
size="small"
/>
<span data-testid={`${baseClass}__install-button--test`}>
{displayActionItems.install.text}
</span>
</Button>
</div>
<div className={`${baseClass}__item-action`}>
{canUninstallSoftware && (
<Button
variant="text-icon"
type="button"
className={`${baseClass}__item-action-button`}
onClick={onClick}
disabled={localStatus === "pending_install"}
onClick={onClickUninstallAction}
disabled={
status === "pending_install" || status === "pending_uninstall"
}
>
{installButtonIcon && (
<Icon
name={installButtonIcon}
color="core-fleet-blue"
size="small"
/>
)}
<span data-testid={`${baseClass}__action-button--test`}>
{installButtonText}
<Icon
name={displayActionItems.uninstall.icon}
color="core-fleet-blue"
size="small"
/>
<span data-testid={`${baseClass}__uninstall-button--test`}>
{displayActionItems.uninstall.text}
</span>
</Button>
) : (
DEFAULT_EMPTY_CELL_VALUE
)}
</div>
</div>
@ -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 = ({
<InstallerStatusAction
deviceToken={deviceToken}
software={cellProps.row.original}
onInstall={onInstall}
onInstallOrUninstall={onInstall}
onClickUninstallAction={() =>
onClickUninstallAction(cellProps.row.original)
}
/>
);
},

View file

@ -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(
<UninstallSoftwareModal
softwareId={1}
softwareName="Slack"
token="abc"
onExit={noop}
onSuccess={noop}
/>
);
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(
<UninstallSoftwareModal
softwareId={1}
token="abc"
onExit={noop}
onSuccess={noop}
/>
);
expect(
screen.getByText(/Uninstalling this software will remove it/i)
).toBeVisible();
});
it("renders the MSI-specific message when installer type is 'msi'", () => {
render(
<UninstallSoftwareModal
softwareId={1}
softwareName="Zoom"
softwareInstallerType="msi"
version="5.0.0"
token="abc"
onExit={noop}
onSuccess={noop}
/>
);
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(
<UninstallSoftwareModal
softwareId={1}
softwareName="Zoom"
softwareInstallerType="pkg"
version="5.0.0"
token="abc"
onExit={noop}
onSuccess={noop}
/>
);
expect(
screen.queryByText(/this will only uninstall version/i)
).not.toBeInTheDocument();
});
});

View file

@ -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 (
<Modal
className={baseClass}
title={`Uninstall ${displaySoftwareName}`}
onExit={onExit}
isContentDisabled={isUninstalling}
>
<>
<p>
Uninstalling this software will remove it and may remove{" "}
{softwareName} data from your device. You can always reinstall it
again later.
</p>
{msiInstaller && (
<p>
By default, this will only uninstall version {version}. To learn how
to uninstall other versions,{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/uninstalling-windows-software`}
text="click here"
newTab
/>
</p>
)}
<div className="modal-cta-wrap">
<Button
variant="alert"
onClick={onUninstallSoftware}
isLoading={isUninstalling}
>
Uninstall
</Button>
<Button variant="inverse-alert" onClick={onExit}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default UninstallSoftwareModal;

View file

@ -0,0 +1,3 @@
.uninstall-software-modal {
overflow-wrap: anywhere; // Prevent long software name overflow
}

View file

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

View file

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

View file

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

View file

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