From e1b325130adb663db986a3b2a31f95a8c2c27658 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:44:17 -0400 Subject: [PATCH] Fleet UI: IPA custom packages (#34220) --- frontend/__mocks__/mdmMock.ts | 31 ++ .../SoftwareInstallDetailsModal.tsx | 4 +- .../SoftwareIpaInstallDetailsModal.tests.tsx | 202 +++++++++ .../SoftwareIpaInstallDetailsModal.tsx | 392 ++++++++++++++++++ .../_styles.scss | 25 ++ .../SoftwareIpaInstallDetailsModal/index.ts | 1 + .../VppInstallDetailsModal.tsx | 4 + .../components/FileUploader/FileUploader.tsx | 4 +- frontend/interfaces/package_type.ts | 10 +- frontend/interfaces/software.ts | 7 + .../cards/ActivityFeed/ActivityFeed.tsx | 20 + .../ScriptUploader/ScriptUploader.tsx | 2 +- .../SoftwareCustomPackage.tsx | 2 +- .../EditSoftwareModal/EditSoftwareModal.tsx | 2 +- .../PackageAdvancedOptions.tsx | 1 + .../forms/PackageForm/PackageForm.tsx | 37 +- .../SoftwareOptionsSelector.tsx | 10 +- .../HostInstallerActionCell.tsx | 5 + .../HostSoftwareLibrary.tsx | 32 +- .../HostSoftwareLibraryTableConfig.tsx | 3 + .../InstallStatusCell.tests.tsx | 23 + .../InstallStatusCell/InstallStatusCell.tsx | 6 + .../Software/SelfService/SelfService.tsx | 32 +- .../SelfService/SelfServiceTableConfig.tsx | 3 + frontend/test/handlers/software-handlers.ts | 46 ++ ...fileUtils.tests.ts => fileUtils.tests.tsx} | 0 .../file/{fileUtils.ts => fileUtils.tsx} | 38 +- 27 files changed, 915 insertions(+), 27 deletions(-) create mode 100644 frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tests.tsx create mode 100644 frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tsx create mode 100644 frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/_styles.scss create mode 100644 frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/index.ts rename frontend/utilities/file/{fileUtils.tests.ts => fileUtils.tests.tsx} (100%) rename frontend/utilities/file/{fileUtils.ts => fileUtils.tsx} (65%) diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index 62ec7b89f2..080c48e5e5 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -1,5 +1,6 @@ import { IHostMdmData } from "interfaces/host"; import { + IMdmCommandResult, IMdmSolution, IMdmProfile, IMdmSummaryMdmSolution, @@ -79,3 +80,33 @@ export const createMockHostMdmData = ( ): IHostMdmData => { return { ...DEFAULT_HOST_MDM_DATA, ...overrides }; }; + +/** + * Creates a mock of an Apple MDM command result. + * Matches the IMdmCommandResult interface. + */ +export const createMockMdmCommandResult = ( + overrides?: Partial +): IMdmCommandResult => { + const defaultPayload = ` + InstallApplication + com.example.MockApp + `; + + const defaultResult = ` + Acknowledged + Installation complete + `; + + return { + host_uuid: "11111111-2222-3333-4444-555555555555", + command_uuid: "mock-command-uuid-1234", + status: "Acknowledged", // or "Error", "NotNow", "200", etc. + updated_at: "2025-08-10T12:05:00Z", + request_type: "InstallApplication", + hostname: "Mock iPhone", + payload: btoa(defaultPayload), + result: btoa(defaultResult), + ...overrides, + }; +}; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx index 6e5057a460..bf489c8edc 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx @@ -1,5 +1,7 @@ /** For payload-free packages (e.g. software source is sh_packages or ps1_packages) - * we use SoftwareScriptDetailsModal */ + * we use SoftwareScriptDetailsModal + * For iOS/iPadOS packages (e.g. .ipa packages software source is ios_apps or ipados_apps) + * we use SoftwareIpaInstallDetailsModal with the command_uuid */ import React, { useState } from "react"; import { useQuery } from "react-query"; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tests.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tests.tsx new file mode 100644 index 0000000000..8a233ce7f8 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tests.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { createCustomRenderer } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { getUniversalSoftwareInstallHandler } from "test/handlers/software-handlers"; +import { createMockHostSoftware } from "__mocks__/hostMock"; + +import SoftwareIpaInstallDetailsModal from "./SoftwareIpaInstallDetailsModal"; + +/** + * Helper for rendering a pre-wired modal component + */ +const renderModal = ( + overrides?: Partial< + React.ComponentProps + > +) => { + const render = createCustomRenderer({ withBackendMock: true }); + return render( + + ); +}; + +describe("SoftwareIpaInstallDetailsModal component", () => { + beforeEach(() => { + mockServer.use(getUniversalSoftwareInstallHandler); + }); + + afterEach(() => { + mockServer.resetHandlers(); + }); + + it("renders NotNow message for an MDM result", async () => { + renderModal({ + details: { + commandUuid: "notnow-uuid", + fleetInstallStatus: "pending_install", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect(screen.getByText(/Fleet tried to install/i)).toBeInTheDocument(); + }); + expect( + screen.getByText( + /because the host was locked or was running on battery power while in Power Nap/i + ) + ).toBeInTheDocument(); + }); + + it("renders Acknowledged pending message", async () => { + renderModal({ + details: { + commandUuid: "acknowledged-uuid", + fleetInstallStatus: "pending_install", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect( + screen.getByText( + /was acknowledged but the installation has not been verified/i + ) + ).toBeInTheDocument(); + }); + expect(screen.getByText(/Refetch/i)).toBeInTheDocument(); + }); + + it("renders normal software install status for non-MDM case", async () => { + renderModal({ + details: { + commandUuid: "uuid-installed", + fleetInstallStatus: "installed", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect(screen.getByText(/Fleet installed/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/Logic Pro/i)).toBeInTheDocument(); + expect(screen.getByText(/Marko's MacBook Pro/i)).toBeInTheDocument(); + }); + + it("renders manual install message when installed not through Fleet", async () => { + renderModal({ + details: { + commandUuid: "", + fleetInstallStatus: "installed", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect(screen.getByText(/Logic Pro/i)).toBeInTheDocument(); + expect(screen.getByText(/is installed\./i)).toBeInTheDocument(); + }); + }); + + it("renders host label as 'the host' if host name empty", async () => { + renderModal({ + details: { + commandUuid: "uuid-installed", + fleetInstallStatus: "installed", + hostDisplayName: "", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect(screen.getByText(/Fleet installed/i)).toBeInTheDocument(); + expect(screen.getByText(/the host/i)).toBeInTheDocument(); + }); + }); + + it("renders Done button by default", async () => { + const onCancel = jest.fn(); + + renderModal({ + onCancel, + details: { + commandUuid: "uuid-installed", + fleetInstallStatus: "installed", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + const doneBtn = await screen.findByRole("button", { name: /done/i }); + doneBtn.click(); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("renders Cancel + Retry when failed_install with deviceAuthToken", async () => { + const onRetry = jest.fn(); + const onCancel = jest.fn(); + + renderModal({ + onRetry, + onCancel, + deviceAuthToken: "test_token_123", + details: { + commandUuid: "uuid-failed", + fleetInstallStatus: "failed_install", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + hostSoftware: createMockHostSoftware({ + id: 99, + name: "CoolApp", + installed_versions: [], + }), + }); + + const cancelButton = await screen.findByRole("button", { name: /cancel/i }); + const retryButton = await screen.findByRole("button", { name: /retry/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(retryButton).toBeInTheDocument(); + + await waitFor(() => { + retryButton.click(); + expect(onRetry).toHaveBeenCalledWith(99); + expect(onCancel).toHaveBeenCalled(); + }); + }); + + it("renders MDM acknowledged result in details output", async () => { + renderModal({ + details: { + commandUuid: "acknowledged-uuid", + fleetInstallStatus: "pending_install", + hostDisplayName: "Marko's MacBook Pro", + appName: "Logic Pro", + }, + }); + + await waitFor(() => { + expect( + screen.getByText( + /acknowledged but the installation has not been verified/i + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tsx new file mode 100644 index 0000000000..4fbf76e1ab --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/SoftwareIpaInstallDetailsModal.tsx @@ -0,0 +1,392 @@ +/** Similar look and feel to the VppInstallDetailsModal, but this modal + * is rendered instead of the SoftwareInstallDetailsModal when the package is + * an .ipa for iOS/iPadOS */ + +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; +import { formatDistanceToNow } from "date-fns"; + +import softwareAPI from "services/entities/software"; +import deviceUserAPI from "services/entities/device_user"; + +import { + IHostSoftware, + ISoftwareIpaInstallResults, + SoftwareInstallUninstallStatus, +} from "interfaces/software"; +import { IMdmCommandResult } from "interfaces/mdm"; + +import InventoryVersions from "pages/hosts/details/components/InventoryVersions"; + +import Modal from "components/Modal"; +import ModalFooter from "components/ModalFooter"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import Textarea from "components/Textarea"; +import DataError from "components/DataError/DataError"; +import DeviceUserError from "components/DeviceUserError"; +import Spinner from "components/Spinner/Spinner"; +import RevealButton from "components/buttons/RevealButton"; + +import { + getInstallDetailsStatusPredicate, + INSTALL_DETAILS_STATUS_ICONS, +} from "../constants"; + +interface IGetStatusMessageProps { + isMyDevicePage?: boolean; + displayStatus: SoftwareInstallUninstallStatus; + isMDMStatusNotNow: boolean; + isMDMStatusAcknowledged: boolean; + appName: string; + hostDisplayName: string; + commandUpdatedAt: string; +} + +export const getStatusMessage = ({ + isMyDevicePage = false, + displayStatus, + isMDMStatusNotNow, + isMDMStatusAcknowledged, + appName, + hostDisplayName, + commandUpdatedAt, +}: IGetStatusMessageProps) => { + const formattedHost = hostDisplayName ? {hostDisplayName} : "the host"; + const displayTimeStamp = + ["failed_install", "installed"].includes(displayStatus || "") && + commandUpdatedAt + ? ` (${formatDistanceToNow(new Date(commandUpdatedAt), { + includeSeconds: true, + addSuffix: true, + })})` + : null; + + const isPendingInstall = displayStatus === "pending_install"; + + // Handles the case where software is installed manually by the user and not through Fleet + // This IPA software_packages modal matches app_store_app modal and software_packages modal + // for software installed manually shown with VppInstallDetailsModal and SoftwareInstallDetailsModal + if (displayStatus === "installed" && !commandUpdatedAt) { + return ( + <> + {appName} is installed. + + ); + } + + // Handle NotNow case separately + if (isMDMStatusNotNow) { + return ( + <> + Fleet tried to install {appName} + {!isMyDevicePage && ( + <> + {" "} + on {formattedHost} but couldn't because the host was locked or + was running on battery power while in Power Nap + + )} + {displayTimeStamp && <> {displayTimeStamp}}. Fleet will try again. + + ); + } + + // IPA Verify command pending state + if (isPendingInstall && isMDMStatusAcknowledged) { + return ( + <> + The MDM command (request) to install {appName} + {!isMyDevicePage && <> on {formattedHost}} was acknowledged but the + installation has not been verified. To re-check, select Refetch + {!isMyDevicePage && " for this host"}. + + ); + } + + // Verification failed (timeout) + if (displayStatus === "failed_install" && isMDMStatusAcknowledged) { + return ( + <> + The MDM command (request) to install {appName} + {!isMyDevicePage && <> on {formattedHost}} was acknowledged but the + installation has not been verified. Please re-attempt this installation. + + ); + } + + // Install command failed + if (displayStatus === "failed_install") { + return ( + <> + The MDM command (request) to install {appName} + {!isMyDevicePage && <> on {formattedHost}} failed + {displayTimeStamp && <> {displayTimeStamp}}. Please re-attempt this + installation. + + ); + } + + const renderSuffix = () => { + if (isMyDevicePage) { + return <> {displayTimeStamp && <> {displayTimeStamp}}; + } + return ( + <> + {" "} + on {formattedHost} + {isPendingInstall && " when it comes online"} + {displayTimeStamp && <> {displayTimeStamp}} + + ); + }; + // Create predicate and subordinate for other statuses + return ( + <> + Fleet {getInstallDetailsStatusPredicate(displayStatus)} {appName} + {renderSuffix()}. + + ); +}; + +interface IModalButtonsProps { + displayStatus: SoftwareInstallUninstallStatus | "pending"; + deviceAuthToken?: string; + onCancel: () => void; + onRetry?: (id: number) => void; + hostSoftwareId?: number; +} + +export const ModalButtons = ({ + displayStatus, + deviceAuthToken, + onCancel, + onRetry, + hostSoftwareId, +}: IModalButtonsProps) => { + const onClickRetry = () => { + // on My Device Page, where this is relevant, both will be defined + if (onRetry && hostSoftwareId) { + onRetry(hostSoftwareId); + } + onCancel(); + }; + + if (deviceAuthToken && displayStatus === "failed_install") { + return ( + + + + + } + /> + ); + } + return ( + Done} /> + ); +}; + +const baseClass = "software-ipa-install-details-modal"; + +export type ISoftwareIpaInstallDetails = { + /** Status: null when a host manually installed not using Fleet */ + fleetInstallStatus: SoftwareInstallUninstallStatus | null; + hostDisplayName: string; + appName: string; + commandUuid?: string; +}; + +interface ISoftwareIpaInstallDetailsModal { + details: ISoftwareIpaInstallDetails; + /** for inventory versions, not present on activity feeds */ + hostSoftware?: IHostSoftware; + /** My Device Page only */ + deviceAuthToken?: string; + onCancel: () => void; + /** My Device Page only */ + onRetry?: (id: number) => void; +} +export const SoftwareIpaInstallDetailsModal = ({ + details, + onCancel, + deviceAuthToken, + hostSoftware, + onRetry, +}: ISoftwareIpaInstallDetailsModal) => { + const { + fleetInstallStatus, + commandUuid = "", + hostDisplayName = "", + appName = "", + } = details; + + const [showInstallDetails, setShowInstallDetails] = useState(false); + const toggleInstallDetails = () => { + setShowInstallDetails((prev) => !prev); + }; + + const { data: swInstallResult, isLoading, isError, error } = useQuery< + ISoftwareIpaInstallResults, + AxiosError, + IMdmCommandResult + >( + ["mdm_command_results", commandUuid], + async () => { + return deviceAuthToken + ? deviceUserAPI.getSoftwareInstallResult(deviceAuthToken, commandUuid) + : softwareAPI.getSoftwareInstallResult(commandUuid); + }, + { + refetchOnWindowFocus: false, + staleTime: 3000, + enabled: !!commandUuid, + select: (data) => data.results, + } + ); + + // Fallback to "installed" if no status is provided + const displayStatus = fleetInstallStatus ?? "installed"; + const iconName = INSTALL_DETAILS_STATUS_ICONS[displayStatus]; + + // Handles "pending" value prior to 4.57 AND never shows error state on pending_install + // as some cases have command results not available for pending_installs + // which we don't want to show a UI error state for + const isPendingInstall = ["pending_install", "pending"].includes( + displayStatus + ); + + // Note: We need to reconcile status values from two different sources. From props, we + // get the status of the Fleet install operation (which can be "failed", "pending", or + // "installed"). From the command results API response, we also receive the raw status + // from the MDM protocol, e.g., "NotNow" or "Acknowledged". We need to display some special + // messaging for the "NotNow" status, which otherwise would be treated as "pending". + const isMDMStatusNotNow = swInstallResult?.status === "NotNow"; + const isMDMStatusAcknowledged = swInstallResult?.status === "Acknowledged"; + + const excludeVersions = + !deviceAuthToken && + ["pending_install", "failed_install", "pending"].includes(displayStatus); + + const isInstalledByFleet = hostSoftware + ? !!hostSoftware.app_store_app?.last_install + : true; // if no hostSoftware passed in, can assume this is the activity feed, meaning this can only refer to a Fleet-handled install + + const statusMessage = getStatusMessage({ + isMyDevicePage: !!deviceAuthToken, + displayStatus, + isMDMStatusNotNow, + isMDMStatusAcknowledged, + appName, + hostDisplayName, + commandUpdatedAt: swInstallResult?.updated_at || "", + }); + + console.log("isMDMStatusNotNow", isMDMStatusNotNow); + const renderInventoryVersionsSection = () => { + if (hostSoftware?.installed_versions?.length) { + return ; + } + return "If you uninstalled it outside of Fleet it will still show as installed."; + }; + + const renderInstallDetailsSection = () => { + return ( + <> + + {showInstallDetails && ( + <> + {swInstallResult?.result && ( + + )} + {swInstallResult?.payload && ( + + )} + + )} + + ); + }; + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError && !isPendingInstall) { + if (error?.status === 404) { + return deviceAuthToken ? ( + + ) : ( + + ); + } + + if (error?.status === 401) { + return deviceAuthToken ? ( + + ) : ( + + ); + } + } else if (!swInstallResult) { + // FIXME: It's currently possible that the command results API response is empty for pending + // commands. As a temporary workaround to handle this case, we'll ignore the empty response and + // display some minimal pending UI. This should be updated once the API response is fixed. + } + return ( +
+
+ {!!iconName && } + {statusMessage} +
+ {hostSoftware && !excludeVersions && renderInventoryVersionsSection()} + {!isPendingInstall && + isInstalledByFleet && + renderInstallDetailsSection()} +
+ ); + }; + + return ( + + <> + {renderContent()} + + + + ); +}; + +export default SoftwareIpaInstallDetailsModal; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/_styles.scss new file mode 100644 index 0000000000..f4209c3d50 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/_styles.scss @@ -0,0 +1,25 @@ +.software-ipa-install-details-modal { + overflow-wrap: anywhere; // Prevent long software name overflow + + &__modal-content { + display: flex; + flex-direction: column; + gap: $pad-medium; + } + + &__status-message { + display: flex; + align-items: center; + gap: $pad-small; + margin: 0; + .icon { + align-self: flex-start; + } + } + .data-set__horizontal { + flex-direction: row; + } + .reveal-button { + width: min-content; + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/index.ts new file mode 100644 index 0000000000..5223b3cd44 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareIpaInstallDetailsModal"; diff --git a/frontend/components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal.tsx index 10cfaf921f..8f8faf13c8 100644 --- a/frontend/components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal.tsx @@ -1,3 +1,7 @@ +/** This modal is only used for VPP apps and their related installations. + * For iOS/iPadOS packages (e.g. .ipa packages software source is ios_apps or ipados_apps) + * we use SoftwareIpaInstallDetailsModal with the command_uuid. */ + import React, { useState } from "react"; import { useQuery } from "react-query"; import { AxiosError } from "axios"; diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index e1bfa9b9ea..87953ffd8e 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -29,7 +29,7 @@ export type ISupportedGraphicNames = Extract< interface IFileUploaderProps { graphicName: ISupportedGraphicNames | ISupportedGraphicNames[]; - message: string; + message: React.ReactNode; title?: string; additionalInfo?: string; /** Controls the loading spinner on the upload button */ @@ -67,7 +67,7 @@ interface IFileUploaderProps { onButtonClick?: () => void; fileDetails?: { name: string; - description?: string; + description?: React.ReactNode; }; /** Indicates that this file uploader deals with an entity that can be managed by GitOps, and so should be disabled when gitops mode is enabled */ gitopsCompatible?: boolean; diff --git a/frontend/interfaces/package_type.ts b/frontend/interfaces/package_type.ts index cd0069311c..e48072bf03 100644 --- a/frontend/interfaces/package_type.ts +++ b/frontend/interfaces/package_type.ts @@ -2,21 +2,25 @@ const fleetMaintainedPackageTypes = ["dmg", "zip"] as const; const unixPackageTypes = ["pkg", "deb", "rpm", "dmg", "zip", "tar.gz"] as const; const windowsPackageTypes = ["msi", "exe"] as const; const scriptOnlyPackageTypes = ["sh", "ps1"] as const; +const iosIpadosPackageTypes = ["ipa"] as const; export const packageTypes = [ ...unixPackageTypes, ...windowsPackageTypes, ...scriptOnlyPackageTypes, + ...iosIpadosPackageTypes, ] as const; export type WindowsPackageType = typeof windowsPackageTypes[number]; export type UnixPackageType = typeof unixPackageTypes[number]; export type FleetMaintainedPackageType = typeof fleetMaintainedPackageTypes[number]; export type ScriptOnlyPackageType = typeof scriptOnlyPackageTypes[number]; +export type IosIpadosPackageType = typeof iosIpadosPackageTypes[number]; export type PackageType = | WindowsPackageType | UnixPackageType | FleetMaintainedPackageType - | ScriptOnlyPackageType; + | ScriptOnlyPackageType + | IosIpadosPackageType; export const isWindowsPackageType = (s: any): s is WindowsPackageType => { return windowsPackageTypes.includes(s); @@ -32,6 +36,10 @@ export const isFleetMaintainedPackageType = ( return fleetMaintainedPackageTypes.includes(s); }; +export const isIosIpadosPackageType = (s: any): s is IosIpadosPackageType => { + return iosIpadosPackageTypes.includes(s); +}; + export const isPackageType = (s: any): s is PackageType => { return packageTypes.includes(s); }; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index dc6b4d3bf4..dd25f3615d 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -6,6 +6,7 @@ import { IconNames } from "components/icons"; import { HOST_APPLE_PLATFORMS, Platform } from "./platform"; import vulnerabilityInterface from "./vulnerability"; import { ILabelSoftwareTitle } from "./label"; +import { IMdmCommandResult } from "./mdm"; export default PropTypes.shape({ type: PropTypes.string, @@ -452,6 +453,11 @@ export interface ISoftwareInstallResults { results: ISoftwareInstallResult; } +/** For Software .ipa installs, we use the install results API to return MDM command results */ +export interface ISoftwareIpaInstallResults { + results: IMdmCommandResult; +} + // ISoftwareInstallerType defines the supported installer types for // software uploaded by the IT admin. export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "rpm" | "exe"; @@ -494,6 +500,7 @@ export interface IHostSoftwarePackage { last_uninstall: ISoftwareLastUninstall | null; categories?: SoftwareCategory[]; automatic_install_policies?: ISoftwareInstallPolicy[] | null; + platform?: Platform; } export interface IHostAppStoreApp { diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index 3e536effe3..91fa73c6f1 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -26,6 +26,7 @@ import Pagination from "components/Pagination"; import VppInstallDetailsModal from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal"; import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal"; import SoftwareScriptDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal"; +import SoftwareIpaInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal"; import SoftwareUninstallDetailsModal, { ISWUninstallDetailsParentState, } from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; @@ -64,6 +65,10 @@ const ActivityFeed = ({ scriptPackageDetails, setScriptPackageDetails, ] = useState(null); + const [ + ipaPackageInstallDetails, + setIpaPackageInstallDetails, + ] = useState(null); const [ packageUninstallDetails, setPackageUninstallDetails, @@ -146,6 +151,9 @@ const ActivityFeed = ({ } else { setPackageInstallDetails({ ...details }); } + details?.command_uuid + ? setIpaPackageInstallDetails({ ...details }) + : setPackageInstallDetails({ ...details }); break; case ActivityType.UninstalledSoftware: setPackageUninstallDetails({ @@ -272,6 +280,18 @@ const ActivityFeed = ({ onCancel={() => setScriptPackageDetails(null)} /> )} + {ipaPackageInstallDetails && ( + setIpaPackageInstallDetails(null)} + /> + )} {packageUninstallDetails && ( )} diff --git a/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx index 051e0e54c9..b7da404d09 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageAdvancedOptions/PackageAdvancedOptions.tsx @@ -31,6 +31,7 @@ const PKG_TYPE_TO_ID_TEXT = { exe: "software name", sh: "package name", ps1: "package name", + ipa: "software name", } as const; const getInstallScriptTooltip = (pkgType: PackageType) => { diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index f3c06cdcd0..2b0f838674 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -57,6 +57,19 @@ export interface IPackageFormValidation { customTarget?: { isValid: boolean }; } +const renderFileTypeMessage = () => { + return ( + <> + macOS (.pkg), iOS/iPadOS (.ipa), +
+ Windows (.msi, .exe.,{" "} + .ps1), + or Linux (.deb, .rpm,{" "} + .sh) + + ); +}; + interface IPackageFormProps { labels: ILabelSummary[]; showSchemaButton?: boolean; @@ -78,7 +91,7 @@ interface IPackageFormProps { } // application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly const ACCEPTED_EXTENSIONS = - ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1"; + ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.ipa"; const PackageForm = ({ labels, @@ -262,15 +275,16 @@ const PackageForm = ({ const isExePackage = ext === "exe"; const isTarballPackage = ext === "tar.gz"; const isScriptPackage = ext === "sh" || ext === "ps1"; + const isIpaPackage = ext === "ipa"; // We currently don't support replacing a tarball package const canEditFile = isEditingSoftware && !isTarballPackage; - // If a user preselects automatic install and then uploads a .exe - // which automatic install is not supported, the form will default - // back to manual install + // If a user preselects automatic install and then uploads a: + // exe, tarball, script, or ipa which automatic install is not supported, + // the form will default back to manual install useEffect(() => { if ( - (isExePackage || isTarballPackage || isScriptPackage) && + (isExePackage || isTarballPackage || isScriptPackage || isIpaPackage) && formData.automaticInstall ) { onToggleAutomaticInstallCheckbox(false); @@ -280,11 +294,13 @@ const PackageForm = ({ isExePackage, isTarballPackage, isScriptPackage, + isIpaPackage, onToggleAutomaticInstallCheckbox, ]); - // Show advanced options when a package is selected that's not a script - const showAdvancedOptions = formData.software && !isScriptPackage; + // Show advanced options when a package is selected that's not a script or ipa + const showAdvancedOptions = + formData.software && !isScriptPackage && !isIpaPackage; // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector const showOptionsTargetsSelectors = !gitOpsModeEnabled; @@ -296,13 +312,15 @@ const PackageForm = ({ canEdit={canEditFile} graphicName="file-pkg" accept={ACCEPTED_EXTENSIONS} - message=".pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, or .ps1" + message={renderFileTypeMessage()} onFileUpload={onFileSelect} buttonMessage="Choose file" buttonType="brand-inverse-icon" className={`${baseClass}__file-uploader`} fileDetails={ - formData.software ? getFileDetails(formData.software) : undefined + formData.software + ? getFileDetails(formData.software, true) + : undefined } gitopsCompatible={false} gitOpsModeEnabled={gitOpsModeEnabled} @@ -332,6 +350,7 @@ const PackageForm = ({ isExePackage={isExePackage} isTarballPackage={isTarballPackage} isScriptPackage={isScriptPackage} + isIpaPackage={isIpaPackage} onClickPreviewEndUserExperience={ onClickPreviewEndUserExperience } diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx index a061da262b..673d482715 100644 --- a/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx +++ b/frontend/pages/SoftwarePage/components/forms/SoftwareOptionsSelector/SoftwareOptionsSelector.tsx @@ -78,6 +78,8 @@ interface ISoftwareOptionsSelector { isTarballPackage?: boolean; /** Script only packages do not have ability to select automatic install */ isScriptPackage?: boolean; + /** IPA packages do not have ability to select automatic install or self-service */ + isIpaPackage?: boolean; /** Edit mode does not have ability to change automatic install */ isEditingSoftware?: boolean; disableOptions?: boolean; @@ -95,12 +97,14 @@ const SoftwareOptionsSelector = ({ isExePackage, isTarballPackage, isScriptPackage, + isIpaPackage, isEditingSoftware, disableOptions = false, }: ISoftwareOptionsSelector) => { const classNames = classnames(baseClass, className); - const isPlatformIosOrIpados = platform === "ios" || platform === "ipados"; + const isPlatformIosOrIpados = + platform === "ios" || platform === "ipados" || isIpaPackage; const isSelfServiceDisabled = disableOptions || isPlatformIosOrIpados; const isAutomaticInstallDisabled = disableOptions || @@ -151,8 +155,8 @@ const SoftwareOptionsSelector = ({ {isPlatformIosOrIpados && (

Currently, self-service and automatic installation are not available - for iOS and iPadOS. Manually install on the Host details page - for each host. + for iOS and iPadOS. Today, you can manually install on the{" "} + Host details page for each host.

)}
diff --git a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell.tsx b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell.tsx index de7310530e..5abea448bc 100644 --- a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell.tsx +++ b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostInstallerActionCell/HostInstallerActionCell.tsx @@ -247,8 +247,13 @@ export const HostInstallerActionCell = ({ "failed_uninstall", ].includes(ui_status); + const isIpaPackage = + (software.source === "ios_apps" || software.source === "ipados_apps") && + !!software_package; + const canUninstallSoftware = !app_store_app && + !isIpaPackage && !!software_package && (installedVersionsDetected || installedTgzPackageDetected); diff --git a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostSoftwareLibrary.tsx b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostSoftwareLibrary.tsx index a763547ad5..2dd6045234 100644 --- a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostSoftwareLibrary.tsx +++ b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/HostSoftwareLibrary.tsx @@ -34,6 +34,7 @@ import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import SoftwareInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal"; +import SoftwareIpaInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal"; import SoftwareScriptDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal"; import VppInstallDetailsModal from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal"; import SoftwareUninstallDetailsModal, { @@ -143,7 +144,7 @@ const HostSoftwareLibrary = ({ selectedSoftwareUpdates, setSelectedSoftwareUpdates, ] = useState(null); - // these states and modal logic exist at this level intead of the page level to match the similar + // these states and modal logic exist at this level instead of the page level to match the similar // pattern on // the device user page, which facilitates manipulating relevant UI states e.g. // "updating..." when the user clicks "Retry" in the SoftwareInstallDetailsModal @@ -151,6 +152,10 @@ const HostSoftwareLibrary = ({ selectedHostSWInstallDetails, setSelectedHostSWInstallDetails, ] = useState(null); + const [ + selectedHostSWIpaInstallDetails, + setSelectedHostSWIpaInstallDetails, + ] = useState(null); const [ selectedHostSWScriptDetails, setSelectedHostSWScriptDetails, @@ -382,6 +387,15 @@ const HostSoftwareLibrary = ({ [setSelectedHostSWInstallDetails] ); + const onSetSelectedHostSWIpaInstallDetails = useCallback( + (hostSW?: IHostSoftware) => { + if (hostSW) { + setSelectedHostSWIpaInstallDetails(hostSW); + } + }, + [setSelectedHostSWIpaInstallDetails] + ); + const onSetSelectedHostSWScriptDetails = useCallback( (hostSW?: IHostSoftware) => { if (hostSW) { @@ -503,6 +517,7 @@ const HostSoftwareLibrary = ({ onShowInventoryVersions, onShowUpdateDetails, onSetSelectedHostSWInstallDetails, + onSetSelectedHostSWIpaInstallDetails, onSetSelectedHostSWScriptDetails, onSetSelectedHostSWUninstallDetails, onSetSelectedVPPInstallDetails, @@ -520,6 +535,7 @@ const HostSoftwareLibrary = ({ onShowInventoryVersions, onShowUpdateDetails, onSetSelectedHostSWInstallDetails, + onSetSelectedHostSWIpaInstallDetails, onSetSelectedHostSWScriptDetails, onSetSelectedHostSWUninstallDetails, onSetSelectedVPPInstallDetails, @@ -592,6 +608,20 @@ const HostSoftwareLibrary = ({ onCancel={() => setSelectedHostSWInstallDetails(null)} /> )} + {selectedHostSWIpaInstallDetails && ( + setSelectedHostSWIpaInstallDetails(null)} + /> + )} {selectedHostSWScriptDetails && ( void; onShowUpdateDetails: (software?: IHostSoftware) => void; onSetSelectedHostSWInstallDetails: (details?: IHostSoftware) => void; + onSetSelectedHostSWIpaInstallDetails: (details?: IHostSoftware) => void; onSetSelectedHostSWScriptDetails: (details?: IHostSoftware) => void; onSetSelectedHostSWUninstallDetails: ( details?: ISWUninstallDetailsParentState @@ -73,6 +74,7 @@ export const generateHostSWLibraryTableHeaders = ({ onShowInventoryVersions, onShowUpdateDetails, onSetSelectedHostSWInstallDetails, + onSetSelectedHostSWIpaInstallDetails, onSetSelectedHostSWScriptDetails, onSetSelectedHostSWUninstallDetails, onSetSelectedVPPInstallDetails, @@ -136,6 +138,7 @@ export const generateHostSWLibraryTableHeaders = ({ onShowInventoryVersions={onShowInventoryVersions} onShowUpdateDetails={onShowUpdateDetails} onShowInstallDetails={onSetSelectedHostSWInstallDetails} + onShowIpaInstallDetails={onSetSelectedHostSWIpaInstallDetails} onShowScriptDetails={onSetSelectedHostSWScriptDetails} onShowVPPInstallDetails={onSetSelectedVPPInstallDetails} onShowUninstallDetails={onSetSelectedHostSWUninstallDetails} 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 37af6229ac..364f2e8883 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tests.tsx @@ -31,6 +31,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -69,6 +70,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -99,6 +101,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -136,6 +139,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -166,6 +170,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -203,6 +208,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -239,6 +245,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -275,6 +282,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -315,6 +323,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -346,6 +355,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -376,6 +386,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -410,6 +421,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -441,6 +453,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -472,6 +485,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -500,6 +514,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -530,6 +545,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -562,6 +578,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -593,6 +610,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -623,6 +641,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -654,6 +673,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -688,6 +708,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -718,6 +739,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} @@ -750,6 +772,7 @@ describe("InstallStatusCell - component", () => { }} onShowUpdateDetails={noop} onShowInstallDetails={noop} + onShowIpaInstallDetails={noop} onShowScriptDetails={noop} onShowUninstallDetails={noop} onShowVPPInstallDetails={noop} diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index b1f9cb0619..f2d8f61bd9 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -338,6 +338,7 @@ type IInstallStatusCellProps = { onShowInventoryVersions?: (software: IHostSoftware) => void; onShowUpdateDetails: (software: IHostSoftware) => void; onShowInstallDetails: (hostSoftware: IHostSoftware) => void; + onShowIpaInstallDetails: (hostSoftware: IHostSoftware) => void; onShowScriptDetails: (hostSoftware: IHostSoftware) => void; onShowVPPInstallDetails: (s: IVPPHostSoftware) => void; onShowUninstallDetails: (details: ISWUninstallDetailsParentState) => void; @@ -385,6 +386,7 @@ const InstallStatusCell = ({ onShowInventoryVersions, onShowUpdateDetails, onShowInstallDetails, + onShowIpaInstallDetails, onShowScriptDetails, onShowVPPInstallDetails, onShowUninstallDetails, @@ -439,6 +441,10 @@ const InstallStatusCell = ({ commandUuid: (lastInstall as IAppLastInstall).command_uuid, }), }); + } + // TODO: Is this the best way to check for IPA installer? + if (software.source === "ios_apps" || software.source === "ipados_apps") { + onShowIpaInstallDetails(software); } else { onShowInstallDetails(software); } diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index 36b6c015df..80b65dd13c 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -31,6 +31,7 @@ import SoftwareUninstallDetailsModal, { ISWUninstallDetailsParentState, } from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal"; import SoftwareInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal"; +import SoftwareIpaInstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareIpaInstallDetailsModal"; import SoftwareScriptDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal"; import { VppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/VppInstallDetailsModal/VppInstallDetailsModal"; @@ -140,6 +141,10 @@ const SoftwareSelfService = ({ selectedHostSWInstallDetails, setSelectedHostSWInstallDetails, ] = useState(undefined); + const [ + selectedHostSWIpaInstallDetails, + setSelectedHostSWIpaInstallDetails, + ] = useState(undefined); const [ selectedHostSWScriptDetails, setSelectedHostSWScriptDetails, @@ -474,6 +479,13 @@ const SoftwareSelfService = ({ [setSelectedHostSWInstallDetails] ); + const onShowIpaInstallDetails = useCallback( + (hostSoftware?: IHostSoftware) => { + setSelectedHostSWIpaInstallDetails(hostSoftware); + }, + [setSelectedHostSWIpaInstallDetails] + ); + const onShowScriptDetails = useCallback( (hostSoftware?: IHostSoftware) => { setSelectedHostSWScriptDetails(hostSoftware); @@ -546,6 +558,7 @@ const SoftwareSelfService = ({ return generateSoftwareTableHeaders({ onShowUpdateDetails, onShowInstallDetails, + onShowIpaInstallDetails, onShowScriptDetails, onShowVPPInstallDetails, onShowUninstallDetails, @@ -556,6 +569,7 @@ const SoftwareSelfService = ({ }, [ onShowUpdateDetails, onShowInstallDetails, + onShowIpaInstallDetails, onShowScriptDetails, onShowVPPInstallDetails, onShowUninstallDetails, @@ -621,9 +635,25 @@ const SoftwareSelfService = ({ contactUrl={contactUrl} /> )} + {selectedHostSWIpaInstallDetails && ( + setSelectedHostSWIpaInstallDetails(undefined)} + deviceAuthToken={deviceToken} + /> + )} {selectedHostSWScriptDetails && ( void; onShowInstallDetails: (hostSoftware: IHostSoftware) => void; + onShowIpaInstallDetails: (hostSoftware: IHostSoftware) => void; onShowScriptDetails: (hostSoftware: IHostSoftware) => void; onShowVPPInstallDetails: (hostSoftware: IVPPHostSoftware) => void; onShowUninstallDetails: ( @@ -56,6 +57,7 @@ interface ISelfServiceTableHeaders { export const generateSoftwareTableHeaders = ({ onShowUpdateDetails, onShowInstallDetails, + onShowIpaInstallDetails, onShowScriptDetails, onShowVPPInstallDetails, onShowUninstallDetails, @@ -101,6 +103,7 @@ export const generateSoftwareTableHeaders = ({ software={cellProps.row.original} onShowUpdateDetails={onShowUpdateDetails} onShowInstallDetails={onShowInstallDetails} + onShowIpaInstallDetails={onShowIpaInstallDetails} onShowScriptDetails={onShowScriptDetails} onShowVPPInstallDetails={onShowVPPInstallDetails} onShowUninstallDetails={onShowUninstallDetails} diff --git a/frontend/test/handlers/software-handlers.ts b/frontend/test/handlers/software-handlers.ts index 6aa5eae5a6..4cd6048be3 100644 --- a/frontend/test/handlers/software-handlers.ts +++ b/frontend/test/handlers/software-handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; import { baseUrl } from "test/test-utils"; import { createMockSoftwareInstallResult } from "__mocks__/softwareMock"; +import { createMockMdmCommandResult } from "__mocks__/mdmMock"; // Installed with outputs export const getDefaultSoftwareInstallHandler = http.get( @@ -46,3 +47,48 @@ export const getSoftwareInstallHandlerOnlyInstallOutput = http.get( }); } ); + +/** + * Generic handler for /software/install/:install_uuid/results + * Returns either a 'SoftwareInstallResult' or an MdmCommandResult[] + * depending on the install_uuid/command_uuid supplied. + */ +export const getUniversalSoftwareInstallHandler = http.get( + baseUrl("/software/install/:install_uuid/results"), + ({ params }) => { + const installUuid = params.install_uuid as string; + + if ( + installUuid.startsWith("mdm-") || + installUuid === "notnow-uuid" || + installUuid === "acknowledged-uuid" + ) { + const statusMap: Record = { + "notnow-uuid": "NotNow", + "acknowledged-uuid": "Acknowledged", + }; + + const status = statusMap[installUuid] || "Acknowledged"; + + const mdmCommand = createMockMdmCommandResult({ + command_uuid: installUuid, + status, + }); + + // Return what Fleet API actually returns + return HttpResponse.json({ + results: mdmCommand, + }); + } + + // Normal fleet install + return HttpResponse.json({ + results: createMockSoftwareInstallResult({ + install_uuid: installUuid, + status: "installed", + output: "Install script ran", + post_install_script_output: "Post-install success", + }), + }); + } +); diff --git a/frontend/utilities/file/fileUtils.tests.ts b/frontend/utilities/file/fileUtils.tests.tsx similarity index 100% rename from frontend/utilities/file/fileUtils.tests.ts rename to frontend/utilities/file/fileUtils.tests.tsx diff --git a/frontend/utilities/file/fileUtils.ts b/frontend/utilities/file/fileUtils.tsx similarity index 65% rename from frontend/utilities/file/fileUtils.ts rename to frontend/utilities/file/fileUtils.tsx index 0be0f52026..eb4f5a55bf 100644 --- a/frontend/utilities/file/fileUtils.ts +++ b/frontend/utilities/file/fileUtils.tsx @@ -1,6 +1,13 @@ +import React from "react"; import { PackageType } from "interfaces/package_type"; +import TooltipWrapper from "components/TooltipWrapper"; -type IPlatformDisplayName = "macOS" | "Windows" | "Linux" | "macOS & Linux"; +type IPlatformDisplayName = + | "macOS" + | "Windows" + | "Linux" + | "iOS/iPadOS" + | "macOS & Linux"; export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record< string, @@ -17,6 +24,7 @@ export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record< "tar.gz": "Linux", sh: "macOS & Linux", ps1: "Windows", + ipa: "iOS/iPadOS", }; /** Currently only using tar.gz, but keeping the others for future use @@ -61,24 +69,42 @@ export const getExtensionFromFileName = (fileName: string) => { return ext as PackageType | undefined; }; -/** This gets the platform display name from the file. */ -export const getPlatformDisplayName = (file: File) => { +/** This gets the platform display name from the file. + * Includes nuance for .sh software installers only supported on Linux + */ +export const getPlatformDisplayName = ( + file: File, + isSoftwareInstaller = false +) => { const fileExt = getExtensionFromFileName(file.name); if (!fileExt) { return undefined; } + if (fileExt === "ipa") { + return ( + + {FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[fileExt]} + + ); + } + + if (fileExt === "sh" && isSoftwareInstaller) { + // Currently, .sh files for software installers are only supported for Linux + return "Linux"; + } + return FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[fileExt]; }; /** This gets the file details from the file. */ -export const getFileDetails = (file: File) => { +export const getFileDetails = (file: File, isSoftwareInstaller = false) => { return { name: file.name, - description: getPlatformDisplayName(file), + description: getPlatformDisplayName(file, isSoftwareInstaller), }; }; export interface IFileDetails { name: string; - description?: string; + description?: React.ReactNode; }