diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts
index 0b89d5321c0..c02b38d639e 100644
--- a/frontend/__mocks__/softwareMock.ts
+++ b/frontend/__mocks__/softwareMock.ts
@@ -224,6 +224,7 @@ const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = {
name: "test.app",
icon_url: null,
software_package: null,
+ packages: null,
app_store_app: null,
source: "apps",
hosts_count: 1,
@@ -260,6 +261,7 @@ export const createMockSoftwareVersionResponse = (
};
const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
+ installer_id: 1,
name: "TestPackage-1.2.3.pkg",
title_id: 2,
version: "1.2.3",
@@ -295,6 +297,7 @@ export const createMockSoftwarePackage = (
};
const DEFAULT_SOFTWARE_PACKAGE_IOS_MOCK: ISoftwarePackage = {
+ installer_id: 2,
name: "MyApp-2.0.0.ipa",
title_id: 10,
version: "2.0.0",
@@ -336,6 +339,7 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
extension_for: "",
versions: [createMockSoftwareTitleVersion()],
software_package: createMockSoftwarePackage(),
+ packages: null,
app_store_app: null,
};
diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx
index eea024a374b..dcc2fe106a6 100644
--- a/frontend/components/InfoBanner/InfoBanner.tsx
+++ b/frontend/components/InfoBanner/InfoBanner.tsx
@@ -20,7 +20,10 @@ export interface IInfoBannerProps {
cta?: JSX.Element;
/** closable and link are mutually exclusive */
closable?: boolean;
- icon?: IconNames; // TODO: This is unused but several banners have icons within children that can be refactored to use this for consistent styling
+ /** Renders an icon to the left of the banner copy. When set, the banner
+ * switches from `space-between` to a left-aligned flex layout so the icon
+ * groups with the text rather than getting pushed to the opposite edge. */
+ icon?: IconNames;
}
const InfoBanner = ({
@@ -46,6 +49,7 @@ const InfoBanner = ({
const content = (
<>
+ {icon && }
{children}
{(cta || closable) && (
diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss
index 4d75d8e9230..9e118ee042d 100644
--- a/frontend/components/InfoBanner/_styles.scss
+++ b/frontend/components/InfoBanner/_styles.scss
@@ -14,6 +14,18 @@
gap: $pad-small;
}
+ // When a leading icon is rendered, group it with the copy on the left
+ // instead of pushing them to opposite edges with `space-between`.
+ &__icon {
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: $pad-small;
+ }
+
+ &__leading-icon {
+ flex-shrink: 0;
+ }
+
&__info {
// Do not use display flex as it will have adverse effects on spacing around HTML tags
align-content: center;
diff --git a/frontend/hooks/useBlockNavigation.ts b/frontend/hooks/useBlockNavigation.ts
new file mode 100644
index 00000000000..fd6b1e7bc1f
--- /dev/null
+++ b/frontend/hooks/useBlockNavigation.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+
+/** Browser navigation guard — shows the leave-confirm prompt while `block`
+ * is true. Tied to the `beforeunload` event, which fires on tab close, hard
+ * navigation, and reload. Used during multi-step uploads where losing the
+ * in-flight request would discard the user's work.
+ *
+ * Note: this does NOT block in-app react-router navigation; only browser-level
+ * navigation. Soft navigation between Fleet routes still proceeds. */
+const useBlockNavigation = (block: boolean): void => {
+ useEffect(() => {
+ if (!block) return undefined;
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault();
+ // Legacy support for Chrome/Edge < 119, which only respect
+ // `returnValue` rather than the modern `preventDefault()`.
+ e.returnValue = true;
+ };
+
+ addEventListener("beforeunload", handler);
+ return () => removeEventListener("beforeunload", handler);
+ }, [block]);
+};
+
+export default useBlockNavigation;
diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts
index 023789e85a7..65bd2d7d3c9 100644
--- a/frontend/interfaces/software.ts
+++ b/frontend/interfaces/software.ts
@@ -123,7 +123,17 @@ export interface IFleetMaintainedVersion {
uploaded_at: string;
}
+// TODO(48400, before merge to main): strip the (#48396/#48397/#48400) issue
+// refs sprinkled through this PR's comments — git blame is the audit trail.
+
export interface ISoftwarePackage {
+ /** Per-installer id — distinct from `title_id`. Used by per-package edit/delete
+ * endpoints so the request targets one specific package on a title that may
+ * have several. Optional during the transition; #48397 sets the canonical
+ * server-side name (could land as `id`, `package_id`, etc. — search-replace
+ * when locked).
+ * TODO(48400): make required once #48397 ships. */
+ installer_id?: number;
name: string;
/** Not included in SoftwareTitle software.software_package response, hoisted up one level
* Custom name set per team by admin
@@ -212,7 +222,11 @@ export interface ISoftwareTitle {
extension_for?: SoftwareExtensionFor;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
+ /** First-added; mirrors packages[0]. Retained for back-compat. */
software_package: ISoftwarePackage | null;
+ /** All custom packages on this title (trimmed shape on list responses). `null`
+ * until the server is on the multi-package contract (#48397). */
+ packages: ISoftwarePackage[] | null;
app_store_app: IAppStoreApp | null;
/** @deprecated Use extension_for instead */
browser?: string;
@@ -225,7 +239,12 @@ export interface ISoftwareTitleDetails {
/** Custom name set per team by admin */
display_name?: string;
icon_url: string | null;
+ /** First-added; mirrors packages[0]. Retained for back-compat. */
software_package: ISoftwarePackage | null;
+ /** All custom packages on this title, in first-added order. `null` until the
+ * server is on the multi-package contract (#48397). When present, treat it
+ * as the source of truth; `software_package` is a convenience alias. */
+ packages: ISoftwarePackage[] | null;
app_store_app: IAppStoreApp | null;
source: SoftwareSource;
extension_for?: SoftwareExtensionFor;
diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
index 6ad47ddb1ac..6292becaa26 100644
--- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery, useQueryClient } from "react-query";
@@ -10,6 +10,7 @@ import softwareAPI from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { AppContext } from "context/app";
+import useBlockNavigation from "hooks/useBlockNavigation";
import useGitOpsMode from "hooks/useGitOpsMode";
import { ILabelSummary } from "interfaces/label";
@@ -72,26 +73,8 @@ const SoftwareCustomPackage = ({
}
);
- useEffect(() => {
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- // Next line with e.returnValue is included for legacy support
- // e.g.Chrome / Edge < 119
- e.returnValue = true;
- };
-
- // set up event listener to prevent user from leaving page while uploading
- if (uploadDetails) {
- addEventListener("beforeunload", beforeUnloadHandler);
- } else {
- removeEventListener("beforeunload", beforeUnloadHandler);
- }
-
- // clean up event listener and timeout on component unmount
- return () => {
- removeEventListener("beforeunload", beforeUnloadHandler);
- };
- }, [uploadDetails]);
+ // Block tab close / hard navigation while an upload is in flight.
+ useBlockNavigation(!!uploadDetails);
const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => {
setShowPreviewEndUserExperience(!showPreviewEndUserExperience);
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
new file mode 100644
index 00000000000..c566de40a02
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+
+import { createCustomRenderer } from "test/test-utils";
+
+import AddPackageModal from "./AddPackageModal";
+
+const BASE_PROPS = {
+ softwareTitleId: 42,
+ teamId: 1,
+ existingPackageName: "GlobalProtect-v6.3.2.pkg",
+ onExit: jest.fn(),
+ onSuccess: jest.fn(),
+};
+
+const renderModal = (
+ overrides: Partial> = {},
+ gitOpsModeEnabled = false
+) => {
+ const render = createCustomRenderer({
+ withBackendMock: true,
+ context: {
+ app: {
+ isPremiumTier: true,
+ isGlobalAdmin: true,
+ config: {
+ gitops: {
+ gitops_mode_enabled: gitOpsModeEnabled,
+ repository_url: gitOpsModeEnabled ? "https://example.com/repo" : "",
+ },
+ },
+ },
+ },
+ });
+ return render();
+};
+
+describe("AddPackageModal", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("standard mode", () => {
+ it("renders with the 'Add package' title", () => {
+ renderModal();
+ // Modal renders its title as a , not a heading; use getByText.
+ expect(screen.getByText("Add package")).toBeInTheDocument();
+ });
+
+ it("renders the multi-package first-added banner copy verbatim", () => {
+ renderModal();
+ // Figma page 2:130 — single source of truth for this string.
+ expect(
+ screen.getByText(
+ "If multiple packages target the same host, Fleet will install the one that was added first."
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("hides the GitOps banner copy", () => {
+ renderModal();
+ expect(
+ screen.queryByText(/Add custom packages in GitOps mode/i)
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("YAML docs")).not.toBeInTheDocument();
+ });
+
+ it("derives the platform label from the existing package's filename", () => {
+ renderModal({ existingPackageName: "GlobalProtect-v6.3.2.pkg" });
+ // `getFileTypeRestriction` returns label "macOS (.pkg)" — the modal
+ // forwards it to PackageForm's FileUploader message slot.
+ expect(screen.getByText("macOS (.pkg)")).toBeInTheDocument();
+ });
+
+ it("falls back to the all-platforms file-type message when the existing name has no recognized extension", () => {
+ renderModal({ existingPackageName: "no-extension" });
+ // PackageForm's default message lists every supported platform.
+ expect(screen.getByText(/macOS \(.pkg,/)).toBeInTheDocument();
+ });
+
+ it("renders the form's Save button as 'Save' (not 'Add software')", async () => {
+ renderModal();
+ // The button text comes from PackageForm — `multiPackageContext` flips
+ // it from "Add software" to "Save". The form mounts after labels load
+ // (an empty array via the optional-chained fallback), so await it.
+ const saveButton = await screen.findByRole("button", { name: "Save" });
+ expect(saveButton).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Add software" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("preselects the Custom target radio (multi-package default per Figma)", () => {
+ renderModal();
+ const customRadio = screen.getByLabelText("Custom");
+ expect(customRadio).toBeChecked();
+ });
+ });
+
+ describe("GitOps mode", () => {
+ it("renders the GitOps banner copy verbatim (apostrophe typo preserved per Figma)", () => {
+ renderModal({}, true);
+ // The "it's" misspelling is intentional — sourced verbatim from
+ // Figma page 2:130 so copy stays in sync with design.
+ expect(
+ screen.getByText(/Add custom packages in GitOps mode/i)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /copy it.s SHA-256 hash into your YAML so the next GitOps workflow doesn.t delete it/i
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("renders the YAML docs CustomLink", () => {
+ renderModal({}, true);
+ const link = screen.getByRole("link", { name: /YAML docs/i });
+ expect(link).toHaveAttribute(
+ "href",
+ expect.stringMatching(/learn-more-about\/software-yaml$/)
+ );
+ });
+
+ it("hides the standard multi-package banner copy in GitOps mode", () => {
+ renderModal({}, true);
+ expect(
+ screen.queryByText(/will install the one that was added first/)
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("file-type restriction (per-row)", () => {
+ it("constrains a Linux .deb title to .deb uploads", () => {
+ renderModal({ existingPackageName: "cinc_18.2.11-1_amd64.deb" });
+ expect(screen.getByText("Linux (.deb)")).toBeInTheDocument();
+ });
+
+ it("constrains a Windows .msi title to .msi uploads", () => {
+ renderModal({ existingPackageName: "ZoomInstaller.msi" });
+ expect(screen.getByText("Windows (.msi)")).toBeInTheDocument();
+ });
+
+ it("constrains a .sh script-only title to .sh uploads", () => {
+ renderModal({ existingPackageName: "setup.sh" });
+ expect(screen.getByText("macOS & Linux (.sh)")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
new file mode 100644
index 00000000000..86de9da4d46
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import { useQuery, useQueryClient } from "react-query";
+
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+import { getFileDetails, IFileDetails } from "utilities/file/fileUtils";
+import softwareAPI from "services/entities/software";
+import labelsAPI, { getCustomLabels } from "services/entities/labels";
+
+import useBlockNavigation from "hooks/useBlockNavigation";
+import useGitOpsMode from "hooks/useGitOpsMode";
+import { ILabelSummary } from "interfaces/label";
+
+import { notify } from "components/ToastNotification";
+import Modal from "components/Modal";
+import FileProgressModal from "components/FileProgressModal";
+import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
+
+import PackageForm from "pages/SoftwarePage/components/forms/PackageForm";
+import { IPackageFormData } from "pages/SoftwarePage/components/forms/PackageForm/PackageForm";
+
+import { getErrorMessage } from "pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/helpers";
+
+import { getFileTypeRestriction } from "./helpers";
+
+const baseClass = "add-package-modal";
+
+interface IAddPackageModalProps {
+ /** The id of the software title we're adding a package to (multi-package
+ * flow, #48397). The POST carries this as `software_title_id` so the new
+ * package attaches to an existing title instead of creating a new one. */
+ softwareTitleId: number;
+ teamId: number;
+ /** File name of the title's first-added package — used to derive the
+ * platform/file-type restriction so the new upload matches the existing
+ * package's platform (e.g. ".pkg" only when the title is a macOS title). */
+ existingPackageName: string;
+ onExit: () => void;
+ /** Fires after a successful upload so the caller can refetch the title's
+ * `packages[]` and surface the new row. */
+ onSuccess: () => void;
+}
+
+const AddPackageModal = ({
+ softwareTitleId,
+ teamId,
+ existingPackageName,
+ onExit,
+ onSuccess,
+}: IAddPackageModalProps) => {
+ const queryClient = useQueryClient();
+ const { gitOpsModeEnabled } = useGitOpsMode("software");
+ const restriction = getFileTypeRestriction(existingPackageName);
+
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [uploadDetails, setUploadDetails] = useState(null);
+ const [
+ showPreviewEndUserExperience,
+ setShowPreviewEndUserExperience,
+ ] = useState(false);
+ const [
+ isIpadOrIphoneSoftwareSource,
+ setIsIpadOrIphoneSoftwareSource,
+ ] = useState(false);
+
+ const { data: labels } = useQuery(
+ ["custom_labels"],
+ () => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)),
+ { ...DEFAULT_USE_QUERY_OPTIONS }
+ );
+
+ // Block tab close / hard navigation while an upload is in flight so the
+ // user doesn't lose their work mid-request.
+ useBlockNavigation(!!uploadDetails);
+
+ const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => {
+ setShowPreviewEndUserExperience(!showPreviewEndUserExperience);
+ setIsIpadOrIphoneSoftwareSource(isIosOrIpadosApp);
+ };
+
+ const onSubmit = async (formData: IPackageFormData) => {
+ if (!formData.software) {
+ notify.error("Couldn't add. Please refresh the page and try again.");
+ return;
+ }
+
+ setUploadDetails(getFileDetails(formData.software));
+
+ try {
+ await softwareAPI.addSoftwarePackage({
+ data: formData,
+ teamId,
+ softwareTitleId,
+ onUploadProgress: (progressEvent) => {
+ const progress = progressEvent.progress || 0;
+ // Keep the progress bar at 97% until the server finalizes its
+ // response — large uploads stall on the last few percent otherwise.
+ setUploadProgress(Math.max(progress - 0.03, 0.01));
+ },
+ });
+
+ if (!gitOpsModeEnabled) {
+ notify.success(
+ <>
+ Successfully added new {formData.software.name} package.
+ >
+ );
+ }
+
+ queryClient.invalidateQueries({
+ queryKey: [{ scope: "software-titles" }],
+ });
+ queryClient.invalidateQueries({
+ queryKey: [{ scope: "software-library" }],
+ });
+
+ onSuccess();
+ } catch (e) {
+ notify.error(getErrorMessage(e), { response: e });
+ }
+ setUploadDetails(null);
+ };
+
+ return (
+ <>
+
+
+
+ {uploadDetails && (
+
+ )}
+ {showPreviewEndUserExperience && (
+
+ )}
+ >
+ );
+};
+
+export default AddPackageModal;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss
new file mode 100644
index 00000000000..e9b293002f3
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss
@@ -0,0 +1,5 @@
+.add-package-modal {
+ // The multi-package and GitOps banners live inside PackageForm
+ // (`package-form__multi-package-banner`) so they sit under the file chooser
+ // alongside the rest of the form's controls. No modal-level styles needed yet.
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
new file mode 100644
index 00000000000..f4949660012
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
@@ -0,0 +1,68 @@
+import { getFileTypeRestriction } from "./helpers";
+
+describe("AddPackageModal helpers — getFileTypeRestriction", () => {
+ it("returns a macOS .pkg restriction for a .pkg filename", () => {
+ expect(getFileTypeRestriction("GlobalProtect-v6.3.2.pkg")).toEqual({
+ accept: ".pkg",
+ label: "macOS (.pkg)",
+ });
+ });
+
+ it("returns a Linux .deb restriction for a .deb filename", () => {
+ expect(getFileTypeRestriction("cinc_18.2.11-1_amd64.deb")).toEqual({
+ accept: ".deb",
+ label: "Linux (.deb)",
+ });
+ });
+
+ it("returns a Windows .msi restriction for a .msi filename", () => {
+ expect(getFileTypeRestriction("ZoomInstaller.msi")).toEqual({
+ accept: ".msi",
+ label: "Windows (.msi)",
+ });
+ });
+
+ // .tar.gz needs the dual MIME/extension workaround because browsers can't
+ // match the compound extension via `accept` alone.
+ it("uses the gzip MIME workaround for .tar.gz", () => {
+ expect(getFileTypeRestriction("bundle-1.0.0.tar.gz")).toEqual({
+ accept: "application/gzip,.tgz",
+ label: "Linux (.tar.gz)",
+ });
+ });
+
+ it("normalizes .tgz aliases through to .tar.gz", () => {
+ // `getExtensionFromFileName` rewrites .tgz → .tar.gz; the restriction
+ // should match.
+ expect(getFileTypeRestriction("bundle.tgz")).toEqual({
+ accept: "application/gzip,.tgz",
+ label: "Linux (.tar.gz)",
+ });
+ });
+
+ it("returns null for an unrecognized extension", () => {
+ expect(getFileTypeRestriction("README.txt")).toBeNull();
+ });
+
+ it("returns null for a filename without an extension", () => {
+ expect(getFileTypeRestriction("installer")).toBeNull();
+ });
+
+ it("returns null for an empty string", () => {
+ expect(getFileTypeRestriction("")).toBeNull();
+ });
+
+ it("returns a macOS & Linux restriction for a .sh script package", () => {
+ expect(getFileTypeRestriction("setup.sh")).toEqual({
+ accept: ".sh",
+ label: "macOS & Linux (.sh)",
+ });
+ });
+
+ it("returns a Windows .ps1 restriction", () => {
+ expect(getFileTypeRestriction("setup.ps1")).toEqual({
+ accept: ".ps1",
+ label: "Windows (.ps1)",
+ });
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
new file mode 100644
index 00000000000..0b8b72916c7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
@@ -0,0 +1,39 @@
+import {
+ FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME,
+ getExtensionFromFileName,
+} from "utilities/file/fileUtils";
+
+/** What the file-uploader should accept and how the file-type hint reads when
+ * adding a new package to a multi-package title. The new upload must match the
+ * existing title's platform/file type, so we derive both from the first-added
+ * package's filename. Returns `null` when we can't determine the restriction
+ * (unknown extension or missing name) so callers fall back to PackageForm's
+ * full all-platforms accept + message. */
+export interface IFileTypeRestriction {
+ /** Value for `` — narrowed to a single extension
+ * (or, for tar.gz, the same MIME/extension pair PackageForm uses globally). */
+ accept: string;
+ /** Display label, e.g. `"macOS (.pkg)"` or `"Linux (.tar.gz)"`. */
+ label: string;
+}
+
+export const getFileTypeRestriction = (
+ existingPackageName: string
+): IFileTypeRestriction | null => {
+ const extension = getExtensionFromFileName(existingPackageName);
+ if (!extension) return null;
+
+ const platform = FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[extension];
+ if (!platform) return null;
+
+ // Browsers can't reliably match `.tar.gz` via extension alone (double-
+ // extension). Mirror PackageForm's global accept value for tar.gz so the
+ // file dialog filters correctly without us reimplementing the workaround.
+ const accept =
+ extension === "tar.gz" ? "application/gzip,.tgz" : `.${extension}`;
+
+ return {
+ accept,
+ label: `${platform} (.${extension})`,
+ };
+};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
new file mode 100644
index 00000000000..0deb5e114c7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddPackageModal";
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
index ee62855ff3f..05bba7ea472 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
@@ -65,6 +65,10 @@ const getPlatformMessage = (isAppStoreApp: boolean, isAndroidApp: boolean) => {
interface IDeleteSoftwareModalProps {
softwareId: number;
teamId: number;
+ /** Per-installer id on a multi-package title (#48397). When set, only this
+ * specific package is deleted; otherwise the request deletes the legacy
+ * single-package row (or VPP/FMA installer slot). */
+ installerId?: number;
onExit: () => void;
onSuccess: () => void;
gitOpsModeEnabled?: boolean;
@@ -75,6 +79,7 @@ interface IDeleteSoftwareModalProps {
const DeleteSoftwareModal = ({
softwareId,
teamId,
+ installerId,
onExit,
onSuccess,
gitOpsModeEnabled,
@@ -86,7 +91,11 @@ const DeleteSoftwareModal = ({
const onDeleteSoftware = useCallback(async () => {
setIsDeleting(true);
try {
- await softwareAPI.deleteSoftwareInstaller(softwareId, teamId);
+ await softwareAPI.deleteSoftwareInstaller(
+ softwareId,
+ teamId,
+ installerId
+ );
notify.success("Successfully deleted software.");
onSuccess();
} catch (error) {
@@ -109,7 +118,7 @@ const DeleteSoftwareModal = ({
}
setIsDeleting(false);
onExit();
- }, [softwareId, teamId, onSuccess, onExit]);
+ }, [softwareId, teamId, installerId, onSuccess, onExit]);
return (
= {
+ softwareId: 1,
+ teamId: 1,
+ softwareInstaller: createMockSoftwarePackage(),
+ refetchSoftwareTitle: jest.fn(),
+ onExit: jest.fn(),
+ installerType: "package",
+ openViewYamlModal: jest.fn(),
+ name: "GlobalProtect",
+ displayName: "GlobalProtect",
+ source: "apps",
+ iconUrl: null,
+};
+
+const renderModal = (
+ overrides: Partial> = {}
+) => {
+ const render = createCustomRenderer({ withBackendMock: true });
+ return render();
+};
+
+describe("EditSoftwareModal — multi-package title (#48400)", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the 'Edit software' title by default (single-package legacy path)", () => {
+ renderModal();
+ expect(screen.getByText("Edit software")).toBeInTheDocument();
+ expect(screen.queryByText("Edit package")).not.toBeInTheDocument();
+ });
+
+ it("renders the 'Edit package' title when canActivateMultiplePackages is true", () => {
+ renderModal({ canActivateMultiplePackages: true });
+ expect(screen.getByText("Edit package")).toBeInTheDocument();
+ expect(screen.queryByText("Edit software")).not.toBeInTheDocument();
+ });
+
+ it("accepts an installerId prop (threaded into the API call on save)", () => {
+ // Smoke test — the prop is optional and exists on the interface. We don't
+ // submit here since that would require asserting against the mocked API
+ // client; the page tests cover the submit path end-to-end.
+ expect(() => renderModal({ installerId: 7 })).not.toThrow();
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
index 82caaebdd59..521c9179ab5 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
@@ -9,6 +9,7 @@ import {
isSoftwarePackage,
InstallerType,
} from "interfaces/software";
+import useBlockNavigation from "hooks/useBlockNavigation";
import useGitOpsMode from "hooks/useGitOpsMode";
import softwareAPI from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
@@ -44,6 +45,10 @@ export type IEditPackageFormData = Omit;
interface IEditSoftwareModalProps {
softwareId: number;
teamId: number;
+ /** Per-installer id on a multi-package title (#48397). When set, the PATCH
+ * targets this specific package; otherwise the request edits the legacy
+ * single-package row. */
+ installerId?: number;
softwareInstaller: ISoftwarePackage | IAppStoreApp;
refetchSoftwareTitle: () => void;
onExit: () => void;
@@ -55,11 +60,17 @@ interface IEditSoftwareModalProps {
displayName: string;
source?: string;
iconUrl?: string | null;
+ /** Passed through from `SoftwareTitleDetailsPage`. When true, the modal
+ * title reads "Edit package" instead of "Edit software" — we're editing one
+ * specific installer on a title that has several, not the title's only
+ * package (#48400). */
+ canActivateMultiplePackages?: boolean;
}
const EditSoftwareModal = ({
softwareId,
teamId,
+ installerId,
softwareInstaller,
onExit,
refetchSoftwareTitle,
@@ -71,6 +82,7 @@ const EditSoftwareModal = ({
displayName,
source,
iconUrl = undefined,
+ canActivateMultiplePackages = false,
}: IEditSoftwareModalProps) => {
const queryClient = useQueryClient();
const { gitOpsModeEnabled } = useGitOpsMode("software");
@@ -147,29 +159,21 @@ const EditSoftwareModal = ({
isUpdatingSoftware,
]);
- /* 1. Delays showing the file progress modal until isUpdatingSoftware
- * has been true for 3 seconds to prevent flashing modal on quick uploads
- * 2. Prevents page unload during the upload
- * 3. Cleans both up when uploading stops or the component unmounts */
+ // Block tab close / hard navigation while the PATCH is in flight.
+ useBlockNavigation(isUpdatingSoftware);
+
+ /* Delays showing the file progress modal until isUpdatingSoftware has been
+ * true for 3 seconds to prevent flashing modal on quick uploads, and
+ * hides it when uploading stops. */
useEffect(() => {
// Timer for delayed modal
let timeoutId: ReturnType | undefined;
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- // Next line with e.returnValue is included for legacy support
- // e.g.Chrome / Edge < 119
- e.returnValue = true;
- };
-
if (isUpdatingSoftware) {
// only show modal if still uploading after 3 seconds
timeoutId = setTimeout(() => {
setShowFileProgressModal(true);
}, 3000);
-
- // Prevents user from leaving page while uploading
- addEventListener("beforeunload", beforeUnloadHandler);
} else {
// upload finished: hide modal and reset
setShowFileProgressModal(false);
@@ -180,7 +184,6 @@ const EditSoftwareModal = ({
if (timeoutId) {
clearTimeout(timeoutId);
}
- removeEventListener("beforeunload", beforeUnloadHandler);
};
}, [isUpdatingSoftware]);
@@ -208,6 +211,7 @@ const EditSoftwareModal = ({
data: formData,
orignalPackage: softwareInstaller as ISoftwarePackage,
softwareId,
+ installerId,
teamId,
onUploadProgress: (progressEvent) => {
const progress = progressEvent.progress || 0;
@@ -252,7 +256,7 @@ const EditSoftwareModal = ({
setIsUpdatingSoftware(false);
};
- const isOnlySelfServiceUpdated = (updates: Record) => {
+ const isOnlySelfServiceUpdated = (updates: Record) => {
return Object.keys(updates).length === 1 && "selfService" in updates;
};
@@ -393,7 +397,7 @@ const EditSoftwareModal = ({
<>
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
index 5b251355b51..16b7c7636e7 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
@@ -32,6 +32,11 @@ const baseProps: ILibraryItemAccordionProps = {
addedAt: new Date("2026-06-15T00:00:00Z").toISOString(),
isActive: true,
canEditSoftware: true,
+ // The base accordion mock represents a Fleet-maintained app — that's the
+ // shape where Latest/Pinned/Major-version badges apply (#48400 scopes the
+ // "Latest" badge to FMA only). Custom-package tests opt out with
+ // `isFma: false, isCustomPackage: true` overrides.
+ isFma: true,
installed: 32,
pending: 5,
failed: 3,
@@ -242,6 +247,93 @@ describe("LibraryItemAccordion", () => {
});
});
+ // #48400 — custom non-FMA non-iOS rows swap the Latest badge for per-
+ // package self-service / auto-install indicators. The page passes
+ // `canActivateMultiplePackages=true` for these rows.
+ describe("custom-package row (multi-package title)", () => {
+ const customRowProps: Partial = {
+ isFma: false,
+ canActivateMultiplePackages: true,
+ };
+
+ it("renders neither Latest nor the per-row icons by default", () => {
+ renderAccordion({ ...customRowProps, badgeState: "latest" });
+
+ expect(
+ screen.queryByRole("button", { name: "Latest" })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Edit package" })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /auto-install polic/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the self-service icon button and fires onSelfServiceClick", async () => {
+ const onSelfServiceClick = jest.fn();
+ const { user } = renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ isSelfService: true,
+ onSelfServiceClick,
+ });
+
+ // Self-service icon opens the per-package Edit modal; aria-label
+ // matches the modal title.
+ const button = screen.getByRole("button", { name: "Edit package" });
+ await user.click(button);
+ expect(onSelfServiceClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the auto-install icon button and fires onAutoInstallClick", async () => {
+ const onAutoInstallClick = jest.fn();
+ const { user } = renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ hasAutoInstallPolicy: true,
+ onAutoInstallClick,
+ });
+
+ const button = screen.getByRole("button", {
+ name: "View auto-install policies",
+ });
+ await user.click(button);
+ expect(onAutoInstallClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses the patch-policy aria-label when isPatchPolicyOnly is true", () => {
+ renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ hasAutoInstallPolicy: true,
+ isPatchPolicyOnly: true,
+ onAutoInstallClick: jest.fn(),
+ });
+
+ expect(
+ screen.getByRole("button", { name: "View patch policy" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "View auto-install policies" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render the Latest badge for a non-FMA row even when badgeState is 'latest'", () => {
+ // The Latest badge is gated on `isFma` (#48400); custom rows never
+ // surface it regardless of `canActivateMultiplePackages` / badgeState.
+ renderAccordion({
+ isFma: false,
+ canActivateMultiplePackages: false,
+ badgeState: "latest",
+ });
+
+ expect(
+ screen.queryByRole("button", { name: "Latest" })
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe("inactive row", () => {
it("hides all badges and the chevron interaction", async () => {
const { user } = renderAccordion({
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
index f61a4700818..044e4ce7581 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
@@ -6,11 +6,13 @@ import CopyButton from "components/buttons/CopyButton";
import CustomLink from "components/CustomLink";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Icon from "components/Icon";
+import { IconNames } from "components/icons";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { ILabelSoftwareTitle } from "interfaces/label";
import { InstallerType } from "interfaces/software";
+import { getSelfServiceTooltip } from "pages/SoftwarePage/helpers";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
const baseClass = "library-item-accordion";
@@ -99,6 +101,32 @@ export interface ILibraryItemAccordionProps {
onLabelsClick?: () => void;
onDownloadClick?: () => void;
onTrashClick?: () => void;
+
+ /** Single page-level flag mirroring `SoftwareTitleDetailsPage`'s
+ * `canActivateMultiplePackages` — true for titles that can hold multiple
+ * custom packages (#48400). Drives the row's self-service / auto-install
+ * icons (and the absence of the Latest badge, which is FMA-only by gate). */
+ canActivateMultiplePackages?: boolean;
+ /** Drives the self-service icon's tooltip variant and visibility on the
+ * custom-package row. Mirrors `software_package.self_service`. */
+ isSelfService?: boolean;
+ /** Drives the auto-install icon's visibility on the custom-package row.
+ * Truthy when the package has ≥1 linked policy (auto-install OR patch). */
+ hasAutoInstallPolicy?: boolean;
+ /** When set, tooltips mention "Patch policy" instead of "Auto install" —
+ * matches the SoftwareSummaryCard chip's tooltip split. */
+ isPatchPolicyOnly?: boolean;
+ /** Self-service tooltip copy varies for Android Play Store apps. Wired
+ * through for completeness even though the current call sites only enable
+ * the custom-package path for desktop titles. */
+ isAndroidPlayStoreApp?: boolean;
+ /** Click handler for the self-service icon — opens the per-package Edit
+ * software modal (same target as the labels-count badge). */
+ onSelfServiceClick?: () => void;
+ /** Click handler for the auto-install icon — the page resolves whether to
+ * navigate straight to the single linked policy or open the PoliciesModal,
+ * scoped to THIS package's policies (#48400). */
+ onAutoInstallClick?: () => void;
}
const ALL_HOSTS_LABEL = "All hosts";
@@ -132,6 +160,13 @@ const LibraryItemAccordion = ({
onLabelsClick,
onDownloadClick,
onTrashClick,
+ canActivateMultiplePackages = false,
+ isSelfService = false,
+ hasAutoInstallPolicy = false,
+ isPatchPolicyOnly = false,
+ isAndroidPlayStoreApp = false,
+ onSelfServiceClick,
+ onAutoInstallClick,
}: ILibraryItemAccordionProps) => {
const [expanded, setExpanded] = useState(false);
@@ -179,12 +214,92 @@ const LibraryItemAccordion = ({
handler?.();
};
+ // Per-row indicator icon — tooltipped, optionally wrapped in a Button when
+ // a click handler is provided AND the caller's gate (e.g. permission) is
+ // open. Falls back to a static Icon otherwise. Used for the self-service
+ // and auto-install indicators on multi-package custom rows (#48400).
+ const renderRowActionIcon = ({
+ iconName,
+ tooltipContent,
+ ariaLabel,
+ onClick,
+ canClick = true,
+ }: {
+ iconName: IconNames;
+ tooltipContent: React.ReactNode;
+ ariaLabel: string;
+ onClick?: () => void;
+ /** When false, the icon stays static even if `onClick` is provided —
+ * used to gate clickability on permission (e.g. self-service). */
+ canClick?: boolean;
+ }) => (
+
+ {onClick && canClick ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ // Self-service tooltip mirrors the SoftwareSummaryCard chip's copy so the
+ // per-row indicator says the same thing the title-level chip would.
+ const renderSelfServiceIcon = () =>
+ renderRowActionIcon({
+ iconName: "user",
+ tooltipContent: getSelfServiceTooltip(
+ !!isIosOrIpadosApp,
+ !!isAndroidPlayStoreApp
+ ),
+ // Same modal opens regardless of which icon is clicked; the icon glyph
+ // carries the contextual signal ("self-service is on for this package").
+ ariaLabel: "Edit package",
+ onClick: onSelfServiceClick,
+ canClick: canEditSoftware,
+ });
+
+ // Auto-install icon navigates rather than edits — its label is verb-forward
+ // ("View") so it doesn't read as a state toggle. Patch-only and dynamic
+ // variants get their own labels for screen-reader clarity.
+ const renderAutoInstallIcon = () =>
+ renderRowActionIcon({
+ iconName: isPatchPolicyOnly ? "policy" : "refresh",
+ tooltipContent: isPatchPolicyOnly ? (
+ <>Patch policy is linked to this package.>
+ ) : (
+ <>Policy triggers install.>
+ ),
+ ariaLabel: isPatchPolicyOnly
+ ? "View patch policy"
+ : "View auto-install policies",
+ onClick: onAutoInstallClick,
+ });
+
const renderHeaderBadges = () => {
if (!isActive) return null;
return (
- {badgeState === "latest" && (
+ {/* The "Latest" badge is FMA-specific — only Fleet-maintained apps
+ have a meaningful "latest available cached version" concept that
+ drives the badge state. VPP / App Store / Play Store / iOS
+ in-house and custom packages do not render this badge.
+ Pinned / Major version variants below also stay FMA-only by
+ construction (only FMA exposes version pinning). */}
+ {isFma && badgeState === "latest" && (
)}
+ {canActivateMultiplePackages &&
+ isSelfService &&
+ renderSelfServiceIcon()}
+ {canActivateMultiplePackages &&
+ hasAutoInstallPolicy &&
+ renderAutoInstallIcon()}
{badgeState === "pinned" && (