add UI for uploading setup experience script (#22691)

relates to #22374

> NOTE: we still need integration with the API which will be done in
another PR.

>NOTE: Please review https://github.com/fleetdm/fleet/pull/22651 first,
as this PR is based off of that branch.

This adds the UI for uploading a setup experience script. this includes:

**setup experience script uploader:**


![image](https://github.com/user-attachments/assets/bec00594-9519-48a9-bce5-d90d282ad5fb)

**script card:**


![image](https://github.com/user-attachments/assets/35549f1c-c280-4293-8e4e-e36e2a3df16c)

**delete script modal:**


![image](https://github.com/user-attachments/assets/90be6bd3-6807-4adb-8709-2ec5893f7fad)

**script run preview:**


![image](https://github.com/user-attachments/assets/cb874986-e0eb-4e6e-bab3-b8325394894c)



<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [ ] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez
2024-10-10 13:19:32 +01:00
committed by GitHub
parent 33e7fc3bd2
commit d03468314e
20 changed files with 466 additions and 4 deletions
@@ -0,0 +1 @@
- add UI for adding a setup experience script
+15
View File
@@ -4,6 +4,7 @@ import {
IMdmProfile,
IMdmSummaryMdmSolution,
} from "interfaces/mdm";
import { IGetSetupExperienceScriptResponse } from "services/entities/mdm";
const DEFAULT_MDM_SOLUTION_MOCK: IMdmSolution = {
id: 1,
@@ -79,3 +80,17 @@ export const createMockHostMdmData = (
): IHostMdmData => {
return { ...DEFAULT_HOST_MDM_DATA, ...overrides };
};
const DEFAULT_SETUP_EXPIERENCE_SCRIPT: IGetSetupExperienceScriptResponse = {
id: 1,
team_id: null,
name: "Test Script",
created_at: "2021-01-01T00:00:00Z",
updated_at: "2021-01-01T00:00:00Z",
};
export const createMockSetupExperienceScript = (
overrides?: Partial<IGetSetupExperienceScriptResponse>
): IGetSetupExperienceScriptResponse => {
return { ...DEFAULT_SETUP_EXPIERENCE_SCRIPT, ...overrides };
};
@@ -6,6 +6,7 @@ import EndUserAuthentication from "./cards/EndUserAuthentication/EndUserAuthenti
import BootstrapPackage from "./cards/BootstrapPackage";
import SetupAssistant from "./cards/SetupAssistant";
import InstallSoftware from "./cards/InstallSoftware";
import SetupExperienceScript from "./cards/SetupExperienceScript";
interface ISetupExperienceCardProps {
currentTeamId?: number;
@@ -39,6 +40,12 @@ const SETUP_EXPERIENCE_NAV_ITEMS: ISideNavItem<
path: PATHS.CONTROLS_INSTALL_SOFTWARE,
Card: InstallSoftware,
},
{
title: "5. Run script",
urlSection: "run-script",
path: PATHS.CONTROLS_RUN_SCRIPT,
Card: SetupExperienceScript,
},
];
export default SETUP_EXPERIENCE_NAV_ITEMS;
@@ -17,7 +17,7 @@ import CustomLink from "components/CustomLink";
import SetupAssistantPreview from "./components/SetupAssistantPreview";
import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUploader";
import SetuAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard";
import SetupAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard";
import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile";
import AdvancedOptionsForm from "./components/AdvancedOptionsForm";
@@ -112,7 +112,7 @@ const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => {
onUpload={onUpload}
/>
) : (
<SetuAssistantProfileCard
<SetupAssistantProfileCard
profile={enrollmentProfileData}
onDelete={() => setShowDeleteProfileModal(true)}
/>
@@ -0,0 +1,103 @@
import React, { useState } from "react";
import { useQuery } from "react-query";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import mdmAPI from "services/entities/mdm";
import SectionHeader from "components/SectionHeader";
import DataError from "components/DataError";
import Spinner from "components/Spinner";
import CustomLink from "components/CustomLink";
import SetupExperiencePreview from "./components/SetupExperienceScriptPreview";
import SetupExperienceScriptUploader from "./components/SetupExperienceScriptUploader";
import SetupExperienceScriptCard from "./components/SetupExperienceScriptCard";
import DeleteSetupExperienceScriptModal from "./components/DeleteSetupExperienceScriptModal";
const baseClass = "setup-experience-script";
interface ISetupExperienceScriptProps {
currentTeamId: number;
}
const SetupExperienceScript = ({
currentTeamId,
}: ISetupExperienceScriptProps) => {
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const {
data: script,
isLoading,
isError,
refetch: refetchScript,
} = useQuery(
["setup-experience-script", currentTeamId],
() => mdmAPI.getSetupExperienceScript(currentTeamId),
{ ...DEFAULT_USE_QUERY_OPTIONS }
);
const onUpload = () => {
refetchScript();
};
const onDelete = () => {
setShowDeleteScriptModal(false);
refetchScript();
};
const scriptUploaded = true;
const renderContent = () => {
if (isLoading) {
<Spinner />;
}
if (isError) {
return <DataError />;
}
return (
<div className={`${baseClass}__content`}>
<div className={`${baseClass}__description-container`}>
<p className={`${baseClass}__description`}>
Upload a script to run on hosts that automatically enroll to Fleet.
</p>
<CustomLink
className={`${baseClass}__learn-how-link`}
newTab
url=""
text="Learn how"
/>
{!scriptUploaded || !script ? (
<SetupExperienceScriptUploader
currentTeamId={currentTeamId}
onUpload={onUpload}
/>
) : (
<SetupExperienceScriptCard
script={script}
onDelete={() => setShowDeleteScriptModal(true)}
/>
)}
</div>
<SetupExperiencePreview />
</div>
);
};
return (
<div className={baseClass}>
<SectionHeader title="Run script" />
<>{renderContent()}</>
{showDeleteScriptModal && (
<DeleteSetupExperienceScriptModal
onDeleted={onDelete}
onExit={() => setShowDeleteScriptModal(false)}
/>
)}
</div>
);
};
export default SetupExperienceScript;
@@ -0,0 +1,17 @@
.setup-experience-script {
&__content {
max-width: $break-xxl;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $pad-xxlarge;
}
&__description {
margin: 0;
}
&__learn-how-link {
margin-bottom: $pad-large;
}
}
@@ -0,0 +1,37 @@
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import React from "react";
const baseClass = "delete-setup-experience-script-modal";
interface IDeleteSetupExperienceScriptModalProps {
onExit: () => void;
onDeleted: () => void;
}
const DeleteSetupExperienceScriptModal = ({
onExit,
onDeleted,
}: IDeleteSetupExperienceScriptModalProps) => {
const onDelete = () => {
onDeleted();
};
return (
<Modal className={baseClass} title="Delete setup script" onExit={onExit}>
<>
<p>Delete the setup script to upload a new one.</p>
<div className="modal-cta-wrap">
<Button type="button" onClick={onDelete} variant="alert">
Delete
</Button>
<Button onClick={onExit} variant="inverse-alert">
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default DeleteSetupExperienceScriptModal;
@@ -0,0 +1 @@
export { default } from "./DeleteSetupExperienceScriptModal";
@@ -0,0 +1,81 @@
import React, { useContext } from "react";
import FileSaver from "file-saver";
import mdmAPI, {
IGetSetupExperienceScriptResponse,
} from "services/entities/mdm";
import { uploadedFromNow } from "utilities/date_format";
import Button from "components/buttons/Button";
import Card from "components/Card";
import Graphic from "components/Graphic";
import Icon from "components/Icon";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import { API_NO_TEAM_ID } from "interfaces/team";
const baseClass = "setup-experience-script-card";
interface ISetupExperienceScriptCardProps {
script: IGetSetupExperienceScriptResponse;
onDelete: () => void;
}
const SetupExperienceScriptCard = ({
script,
onDelete,
}: ISetupExperienceScriptCardProps) => {
const { renderFlash } = useContext(NotificationContext);
const onDownload = async () => {
try {
const teamId = script.team_id ?? API_NO_TEAM_ID;
await mdmAPI.downloadSetupExperienceScript(teamId);
} catch (e) {
renderFlash("error", getErrorReason(e));
}
// TODO: download script integration
// const date = new Date();
// const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${
// script.name
// }`;
// const file = new global.window.File(
// [JSON.stringify(script.enrollment_profile)],
// filename
// );
// FileSaver.saveAs(file);
};
return (
<Card paddingSize="medium" className={baseClass}>
<Graphic name="file-sh" />
<div className={`${baseClass}__info`}>
<span className={`${baseClass}__profile-name`}>{script.name}</span>
<span className={`${baseClass}__uploaded-at`}>
{uploadedFromNow(script.created_at)}
</span>
</div>
<div className={`${baseClass}__actions`}>
<Button
className={`${baseClass}__download-button`}
variant="text-icon"
onClick={onDownload}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__delete-button`}
variant="text-icon"
onClick={onDelete}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</div>
</Card>
);
};
export default SetupExperienceScriptCard;
@@ -0,0 +1,32 @@
.setup-experience-script-card {
display: flex;
gap: $pad-medium;
align-items: center;
// TODO: create reusable list item component and use instead of all these styles.
&__info {
display: flex;
flex-direction: column;
}
&__profile-name {
font-size: $x-small;
font-weight: $bold;
}
&__uploaded-at {
font-size: $xx-small;
}
&__actions {
display: flex;
gap: $pad-medium;
flex: 1;
justify-content: flex-end;
}
&__download-button,
&__delete-button {
padding: 11px; // TODO: use a padding value from existing variables. talk to design.
}
}
@@ -0,0 +1 @@
export { default } from "./SetupExperienceScriptCard";
@@ -0,0 +1,31 @@
import React from "react";
import Card from "components/Card";
import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png";
const baseClass = "setup-experience-script-preview";
const SetupExperienceScriptPreview = () => {
return (
<Card color="gray" paddingSize="xxlarge" className={baseClass}>
<h3>End user experience</h3>
<p>
When the end user completes the macOS Setup Assistant, they will see
scripts being run. User will not be able to continue until scripts
complete.
</p>
<p>
If there are any errors, the end user will be able to continue and will
be instructed to contact their IT department.
</p>
<img
className={`${baseClass}__preview-img`}
src={InstallSoftwarePreviewImg}
alt="install software preview"
/>
</Card>
);
};
export default SetupExperienceScriptPreview;
@@ -0,0 +1,13 @@
.setup-experience-script-preview {
h3 {
margin: 0;
font-size: $small;
font-weight: normal;
}
&__preview-img {
margin-top: $pad-xxlarge;
width: 100%;
display: block;
}
}
@@ -0,0 +1 @@
export { default } from "./SetupExperienceScriptPreview";
@@ -0,0 +1,63 @@
import React, { useContext, useState } from "react";
import classnames from "classnames";
import mdmAPI from "services/entities/mdm";
import { NotificationContext } from "context/notification";
import FileUploader from "components/FileUploader";
import { getErrorReason } from "interfaces/errors";
const baseClass = "setup-experience-script-uploader";
interface ISetupExperienceScriptUploaderProps {
currentTeamId: number;
onUpload: () => void;
className?: string;
}
const SetupExperienceScriptUploader = ({
currentTeamId,
onUpload,
className,
}: ISetupExperienceScriptUploaderProps) => {
const { renderFlash } = useContext(NotificationContext);
const [showLoading, setShowLoading] = useState(false);
const classNames = classnames(baseClass, className);
const onUploadFile = async (files: FileList | null) => {
setShowLoading(true);
if (!files || files.length === 0) {
setShowLoading(false);
return;
}
const file = files[0];
try {
await mdmAPI.uploadSetupExperienceScript(file, currentTeamId);
renderFlash("success", "Successfully uploaded!");
onUpload();
} catch (e) {
// TODO: what errors?
renderFlash("error", getErrorReason(e));
}
setShowLoading(false);
};
return (
<FileUploader
className={classNames}
message="Shell (.sh) for macOS"
graphicName="file-sh"
accept=".sh"
buttonMessage="Upload"
onFileUpload={onUploadFile}
isLoading={showLoading}
/>
);
};
export default SetupExperienceScriptUploader;
@@ -0,0 +1 @@
export { default } from "./SetupExperienceScriptUploader";
@@ -0,0 +1 @@
export { default } from "./SetupExperienceScript";
+1
View File
@@ -18,6 +18,7 @@ export default {
CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`,
CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`,
CONTROLS_INSTALL_SOFTWARE: `${URL_PREFIX}/controls/setup-experience/install-software`,
CONTROLS_RUN_SCRIPT: `${URL_PREFIX}/controls/setup-experience/run-script`,
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
// Dashboard pages
+54 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { createMockMdmProfile } from "__mocks__/mdmMock";
import { createMockSetupExperienceScript } from "__mocks__/mdmMock";
import {
DiskEncryptionStatus,
IHostMdmProfile,
@@ -82,6 +82,14 @@ export interface IGetMdmCommandResultsResponse {
results: IMdmCommandResult[];
}
export interface IGetSetupExperienceScriptResponse {
id: number;
team_id: number | null; // The API return null for no team in this case.
name: string;
created_at: string;
updated_at: string;
}
const mdmService = {
unenrollHostFromMdm: (hostId: number, timeout?: number) => {
const { HOST_MDM_UNENROLL } = endpoints;
@@ -276,6 +284,7 @@ const mdmService = {
return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body);
},
getSetupEnrollmentProfile: (teamId?: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
if (!teamId || teamId === API_NO_TEAM_ID) {
@@ -287,6 +296,7 @@ const mdmService = {
)}`;
return sendRequest("GET", path);
},
uploadSetupEnrollmentProfile: (file: File, teamId: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
@@ -313,6 +323,7 @@ const mdmService = {
});
});
},
deleteSetupEnrollmentProfile: (teamId: number) => {
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
if (teamId === API_NO_TEAM_ID) {
@@ -324,6 +335,7 @@ const mdmService = {
)}`;
return sendRequest("DELETE", path);
},
getCommandResults: (
command_uuid: string
): Promise<IGetMdmCommandResultsResponse> => {
@@ -331,6 +343,47 @@ const mdmService = {
const url = `${MDM_COMMANDS_RESULTS}?command_uuid=${command_uuid}`;
return sendRequest("GET", url);
},
getSetupExperienceScript: (
teamId: number
): Promise<IGetSetupExperienceScriptResponse> => {
const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
let path = MDM_SETUP_EXPERIENCE_SCRIPT;
if (teamId) {
path += `?${buildQueryStringFromParams({ team_id: teamId })}`;
}
return new Promise((resolve, reject) => {
resolve(createMockSetupExperienceScript());
});
// return sendRequest("GET", path);
},
downloadSetupExperienceScript: (teamId: number) => {
const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
let path = MDM_SETUP_EXPERIENCE_SCRIPT;
if (teamId) {
path += `?${buildQueryStringFromParams({ team_id: teamId })}`;
}
return sendRequest("GET", path);
},
uploadSetupExperienceScript: (file: File, teamId: number) => {
const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints;
const formData = new FormData();
formData.append("script", file);
if (teamId) {
formData.append("team_id", teamId.toString());
}
return sendRequest("POST", MDM_SETUP_EXPERIENCE_SCRIPT, formData);
},
};
export default mdmService;
+4 -1
View File
@@ -127,7 +127,6 @@ export default {
MDM_BOOTSTRAP_PACKAGE: `/${API_VERSION}/fleet/mdm/bootstrap`,
MDM_BOOTSTRAP_PACKAGE_SUMMARY: `/${API_VERSION}/fleet/mdm/bootstrap/summary`,
MDM_SETUP: `/${API_VERSION}/fleet/mdm/apple/setup`,
MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`,
MDM_EULA: (token: string) => `/${API_VERSION}/fleet/mdm/setup/eula/${token}`,
MDM_EULA_UPLOAD: `/${API_VERSION}/fleet/mdm/setup/eula`,
MDM_EULA_METADATA: `/${API_VERSION}/fleet/mdm/setup/eula/metadata`,
@@ -139,6 +138,10 @@ export default {
ME: `/${API_VERSION}/fleet/me`,
// Setup experiece endpoints
MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`,
MDM_SETUP_EXPERIENCE_SCRIPT: `/${API_VERSION}/fleet/setup_experience/script`,
// OS Version endpoints
OS_VERSIONS: `/${API_VERSION}/fleet/os_versions`,
OS_VERSION: (id: number) => `/${API_VERSION}/fleet/os_versions/${id}`,