mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
- **Gitops specify FMA rollback version (#39582)** - **Fleet UI: Show versions options for FMA installers (#39583)** - **rollback: DB and core implementation (#39650)** <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #31919 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually --------- Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Co-authored-by: Carlo DiCelico <carlo@fleetdm.com>
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import React, { useContext, useState, useEffect } from "react";
|
|
import { InjectedRouter } from "react-router";
|
|
import { useQuery } from "react-query";
|
|
import classnames from "classnames";
|
|
|
|
import { ILabelSummary } from "interfaces/label";
|
|
import {
|
|
IAppStoreApp,
|
|
ISoftwarePackage,
|
|
isSoftwarePackage,
|
|
InstallerType,
|
|
} from "interfaces/software";
|
|
import { NotificationContext } from "context/notification";
|
|
import { AppContext } from "context/app";
|
|
import softwareAPI from "services/entities/software";
|
|
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
|
|
|
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
|
import deepDifference from "utilities/deep_difference";
|
|
import { getFileDetails } from "utilities/file/fileUtils";
|
|
|
|
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 SoftwareVppForm from "pages/SoftwarePage/components/forms/SoftwareVppForm";
|
|
import { ISoftwareVppFormData } from "pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm";
|
|
import {
|
|
generateSelectedLabels,
|
|
getCustomTarget,
|
|
getInstallType,
|
|
getTargetType,
|
|
} from "pages/SoftwarePage/helpers";
|
|
|
|
import { getErrorMessage } from "./helpers";
|
|
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
|
|
|
|
const baseClass = "edit-software-modal";
|
|
|
|
// Install type used on add but not edit
|
|
export type IEditPackageFormData = Omit<IPackageFormData, "installType">;
|
|
|
|
interface IEditSoftwareModalProps {
|
|
softwareId: number;
|
|
teamId: number;
|
|
softwareInstaller: ISoftwarePackage | IAppStoreApp;
|
|
refetchSoftwareTitle: () => void;
|
|
onExit: () => void;
|
|
installerType: InstallerType;
|
|
router: InjectedRouter;
|
|
openViewYamlModal: () => void;
|
|
isFleetMaintainedApp?: boolean;
|
|
isIosOrIpadosApp?: boolean;
|
|
gitOpsModeEnabled?: boolean;
|
|
name: string;
|
|
displayName: string;
|
|
source?: string;
|
|
iconUrl?: string | null;
|
|
}
|
|
|
|
const EditSoftwareModal = ({
|
|
softwareId,
|
|
teamId,
|
|
softwareInstaller,
|
|
onExit,
|
|
refetchSoftwareTitle,
|
|
installerType,
|
|
router,
|
|
openViewYamlModal,
|
|
isFleetMaintainedApp = false,
|
|
isIosOrIpadosApp = false,
|
|
name,
|
|
displayName,
|
|
source,
|
|
iconUrl = undefined,
|
|
}: IEditSoftwareModalProps) => {
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
const { config } = useContext(AppContext);
|
|
|
|
const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled || false;
|
|
// Viewing an FMA in GitOps mode only allows viewing options, not editing
|
|
const isGitOpsCompatible = gitOpsModeEnabled && isFleetMaintainedApp;
|
|
|
|
const formClassNames = classnames(`${baseClass}__package-form`, {
|
|
[`${baseClass}__package-form--disabled`]: isGitOpsCompatible,
|
|
});
|
|
|
|
const [editSoftwareModalClasses, setEditSoftwareModalClasses] = useState(
|
|
baseClass
|
|
);
|
|
const [isUpdatingSoftware, setIsUpdatingSoftware] = useState(false);
|
|
const [
|
|
showConfirmSaveChangesModal,
|
|
setShowConfirmSaveChangesModal,
|
|
] = useState(false);
|
|
const [
|
|
showPreviewEndUserExperienceModal,
|
|
setShowPreviewEndUserExperienceModal,
|
|
] = useState(false);
|
|
|
|
const [
|
|
pendingPackageUpdates,
|
|
setPendingPackageUpdates,
|
|
] = useState<IEditPackageFormData>({
|
|
software: null,
|
|
installScript: "",
|
|
selfService: false,
|
|
automaticInstall: false,
|
|
targetType: "",
|
|
customTarget: "",
|
|
labelTargets: {},
|
|
categories: [],
|
|
});
|
|
const [
|
|
pendingVppUpdates,
|
|
setPendingVppUpdates,
|
|
] = useState<ISoftwareVppFormData>({
|
|
selfService: false,
|
|
automaticInstall: false,
|
|
targetType: "",
|
|
customTarget: "",
|
|
labelTargets: {},
|
|
categories: [],
|
|
});
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
const [showFileProgressModal, setShowFileProgressModal] = useState(false);
|
|
|
|
const { data: labels } = useQuery<ILabelSummary[], Error>(
|
|
["custom_labels"],
|
|
() => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)),
|
|
{
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
}
|
|
);
|
|
|
|
// Work around to not lose Edit Software modal data when Save changes modal opens
|
|
// by using CSS to hide Edit Software modal when Save changes modal is open
|
|
useEffect(() => {
|
|
setEditSoftwareModalClasses(
|
|
classnames(baseClass, {
|
|
[`${baseClass}--hidden`]:
|
|
showConfirmSaveChangesModal ||
|
|
showPreviewEndUserExperienceModal ||
|
|
(!!pendingPackageUpdates.software && isUpdatingSoftware),
|
|
})
|
|
);
|
|
}, [
|
|
showConfirmSaveChangesModal,
|
|
showPreviewEndUserExperienceModal,
|
|
pendingPackageUpdates.software,
|
|
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 */
|
|
useEffect(() => {
|
|
// Timer for delayed modal
|
|
let timeoutId: ReturnType<typeof setTimeout> | 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);
|
|
}
|
|
|
|
// Cleanup that runs when isUpdatingSoftware changes or component unmounts
|
|
return () => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
removeEventListener("beforeunload", beforeUnloadHandler);
|
|
};
|
|
}, [isUpdatingSoftware]);
|
|
|
|
// Close confirm modal when file progress modal opens
|
|
useEffect(() => {
|
|
if (showFileProgressModal) {
|
|
setShowConfirmSaveChangesModal(false);
|
|
}
|
|
}, [showFileProgressModal]);
|
|
|
|
const toggleConfirmSaveChangesModal = () => {
|
|
setShowConfirmSaveChangesModal(!showConfirmSaveChangesModal);
|
|
};
|
|
|
|
const togglePreviewEndUserExperienceModal = () => {
|
|
setShowPreviewEndUserExperienceModal(!showPreviewEndUserExperienceModal);
|
|
};
|
|
|
|
// Edit package API call
|
|
const onEditPackage = async (formData: IEditPackageFormData) => {
|
|
setIsUpdatingSoftware(true);
|
|
|
|
try {
|
|
await softwareAPI.editSoftwarePackage({
|
|
data: formData,
|
|
orignalPackage: softwareInstaller as ISoftwarePackage,
|
|
softwareId,
|
|
teamId,
|
|
onUploadProgress: (progressEvent) => {
|
|
const progress = progressEvent.progress || 0;
|
|
// for large uploads it seems to take a bit for the server to finalize its response so we'll keep the
|
|
// progress bar at 97% until the server response is received
|
|
setUploadProgress(Math.max(progress - 0.03, 0.01));
|
|
},
|
|
});
|
|
|
|
if (
|
|
isSoftwarePackage(softwareInstaller) &&
|
|
softwareInstaller.title_id &&
|
|
gitOpsModeEnabled
|
|
) {
|
|
// No longer flash message, we open YAML modal if editing with gitOpsModeEnabled
|
|
openViewYamlModal();
|
|
} else {
|
|
renderFlash(
|
|
"success",
|
|
<>
|
|
Successfully edited <b>{formData.software?.name}</b>.
|
|
{formData.selfService
|
|
? " The end user can install from Fleet Desktop."
|
|
: ""}
|
|
</>
|
|
);
|
|
}
|
|
refetchSoftwareTitle();
|
|
onExit();
|
|
} catch (e) {
|
|
renderFlash(
|
|
"error",
|
|
getErrorMessage(e, softwareInstaller as IAppStoreApp)
|
|
);
|
|
}
|
|
setIsUpdatingSoftware(false);
|
|
};
|
|
|
|
const isOnlySelfServiceUpdated = (updates: Record<string, any>) => {
|
|
return Object.keys(updates).length === 1 && "selfService" in updates;
|
|
};
|
|
|
|
const onClickSavePackage = (formData: IPackageFormData) => {
|
|
const softwarePackage = softwareInstaller as ISoftwarePackage;
|
|
|
|
const currentData = {
|
|
software: null,
|
|
installScript: softwarePackage.install_script || "",
|
|
preInstallQuery: softwarePackage.pre_install_query || "",
|
|
postInstallScript: softwarePackage.post_install_script || "",
|
|
uninstallScript: softwarePackage.uninstall_script || "",
|
|
selfService: softwarePackage.self_service || false,
|
|
installType: getInstallType(softwarePackage),
|
|
targetType: getTargetType(softwarePackage),
|
|
customTarget: getCustomTarget(softwarePackage),
|
|
labelTargets: generateSelectedLabels(softwarePackage),
|
|
};
|
|
|
|
setPendingPackageUpdates(formData);
|
|
|
|
const updates = deepDifference(formData, currentData);
|
|
|
|
// Send an array with an empty string when all categories are unchecked
|
|
// so that the "categories" key is included in the multipart form data and
|
|
// will be deleted rather than ignored (an empty array would skip the field)
|
|
if (!formData.categories?.length) {
|
|
formData.categories = [""];
|
|
}
|
|
|
|
if (isOnlySelfServiceUpdated(updates)) {
|
|
onEditPackage(formData);
|
|
} else {
|
|
setShowConfirmSaveChangesModal(true);
|
|
}
|
|
};
|
|
|
|
// Edit App Store API call -- currently only for VPP apps and not Google Play apps
|
|
const onEditVpp = async (formData: ISoftwareVppFormData) => {
|
|
setIsUpdatingSoftware(true);
|
|
|
|
try {
|
|
await softwareAPI.editAppStoreApp(softwareId, teamId, formData);
|
|
|
|
renderFlash(
|
|
"success",
|
|
<>
|
|
Successfully edited <b>{softwareInstaller.name}</b>.
|
|
{formData.selfService
|
|
? " The end user can install from Fleet Desktop."
|
|
: ""}
|
|
</>
|
|
);
|
|
onExit();
|
|
refetchSoftwareTitle();
|
|
} catch (e) {
|
|
renderFlash(
|
|
"error",
|
|
getErrorMessage(e, softwareInstaller as IAppStoreApp)
|
|
);
|
|
}
|
|
setIsUpdatingSoftware(false);
|
|
};
|
|
|
|
const onClickSaveVpp = async (formData: ISoftwareVppFormData) => {
|
|
const currentData = {
|
|
selfService: softwareInstaller.self_service || false,
|
|
automaticInstall: softwareInstaller.automatic_install || false,
|
|
targetType: getTargetType(softwareInstaller),
|
|
customTarget: getCustomTarget(softwareInstaller),
|
|
labelTargets: generateSelectedLabels(softwareInstaller),
|
|
};
|
|
|
|
setPendingVppUpdates(formData);
|
|
|
|
const updates = deepDifference(formData, currentData);
|
|
|
|
if (isOnlySelfServiceUpdated(updates)) {
|
|
onEditVpp(formData);
|
|
} else {
|
|
setShowConfirmSaveChangesModal(true);
|
|
}
|
|
};
|
|
|
|
const onClickConfirmChanges = () => {
|
|
if (installerType === "package") {
|
|
onEditPackage(pendingPackageUpdates);
|
|
} else {
|
|
onEditVpp(pendingVppUpdates);
|
|
}
|
|
};
|
|
|
|
const renderForm = () => {
|
|
if (installerType === "package") {
|
|
const softwarePackage = softwareInstaller as ISoftwarePackage;
|
|
return (
|
|
<PackageForm
|
|
labels={labels || []}
|
|
className={formClassNames}
|
|
isEditingSoftware
|
|
isFleetMaintainedApp={isFleetMaintainedApp}
|
|
onCancel={onExit}
|
|
onSubmit={onClickSavePackage}
|
|
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
|
|
defaultSoftware={softwareInstaller}
|
|
defaultInstallScript={softwarePackage.install_script}
|
|
defaultPreInstallQuery={softwarePackage.pre_install_query}
|
|
defaultPostInstallScript={softwarePackage.post_install_script}
|
|
defaultUninstallScript={softwarePackage.uninstall_script}
|
|
defaultSelfService={softwarePackage.self_service}
|
|
defaultCategories={softwarePackage.categories}
|
|
gitopsCompatible={isGitOpsCompatible}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SoftwareVppForm
|
|
labels={labels || []}
|
|
softwareVppForEdit={softwareInstaller as IAppStoreApp}
|
|
onSubmit={onClickSaveVpp}
|
|
onCancel={onExit}
|
|
isLoading={isUpdatingSoftware}
|
|
onClickPreviewEndUserExperience={togglePreviewEndUserExperienceModal}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
className={editSoftwareModalClasses}
|
|
title="Edit software"
|
|
onExit={onExit}
|
|
width="large"
|
|
>
|
|
{renderForm()}
|
|
</Modal>
|
|
{showConfirmSaveChangesModal && (
|
|
<ConfirmSaveChangesModal
|
|
onClose={toggleConfirmSaveChangesModal}
|
|
softwareInstallerName={softwareInstaller?.name}
|
|
installerType={installerType}
|
|
onSaveChanges={onClickConfirmChanges}
|
|
isLoading={isUpdatingSoftware}
|
|
/>
|
|
)}
|
|
{showPreviewEndUserExperienceModal && (
|
|
<CategoriesEndUserExperienceModal
|
|
name={name}
|
|
displayName={displayName}
|
|
source={source}
|
|
iconUrl={iconUrl} // Must be software title icon url not installer icon url
|
|
onCancel={togglePreviewEndUserExperienceModal}
|
|
isIosOrIpadosApp={isIosOrIpadosApp}
|
|
mobileVersion={
|
|
("latest_version" in softwareInstaller &&
|
|
softwareInstaller.latest_version) ||
|
|
softwareInstaller.version
|
|
}
|
|
/>
|
|
)}
|
|
{!!pendingPackageUpdates.software && showFileProgressModal && (
|
|
<FileProgressModal
|
|
fileDetails={getFileDetails(pendingPackageUpdates.software)}
|
|
fileProgress={uploadProgress}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default EditSoftwareModal;
|