diff --git a/changes/22374-add-ui-for-setup-experience-script b/changes/22374-add-ui-for-setup-experience-script new file mode 100644 index 0000000000..c598cb0d8c --- /dev/null +++ b/changes/22374-add-ui-for-setup-experience-script @@ -0,0 +1 @@ +- add UI for adding a setup experience script diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index 62ec7b89f2..2ae24db8b0 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -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 => { + return { ...DEFAULT_SETUP_EXPIERENCE_SCRIPT, ...overrides }; +}; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx index d018c7ea52..88ff9b3d48 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx @@ -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; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx index f670a8e710..fc6072abbc 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx @@ -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} /> ) : ( - setShowDeleteProfileModal(true)} /> diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx new file mode 100644 index 0000000000..39b09783a4 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx @@ -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) { + ; + } + + if (isError) { + return ; + } + + return ( +
+
+

+ Upload a script to run on hosts that automatically enroll to Fleet. +

+ + {!scriptUploaded || !script ? ( + + ) : ( + setShowDeleteScriptModal(true)} + /> + )} +
+ +
+ ); + }; + + return ( +
+ + <>{renderContent()} + {showDeleteScriptModal && ( + setShowDeleteScriptModal(false)} + /> + )} +
+ ); +}; + +export default SetupExperienceScript; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss new file mode 100644 index 0000000000..0b7a6bc18d --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx new file mode 100644 index 0000000000..e646f239ee --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx @@ -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 ( + + <> +

Delete the setup script to upload a new one.

+
+ + +
+ +
+ ); +}; + +export default DeleteSetupExperienceScriptModal; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts new file mode 100644 index 0000000000..f608071e62 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteSetupExperienceScriptModal"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx new file mode 100644 index 0000000000..05d067d828 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx @@ -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 ( + + +
+ {script.name} + + {uploadedFromNow(script.created_at)} + +
+
+ + +
+
+ ); +}; + +export default SetupExperienceScriptCard; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss new file mode 100644 index 0000000000..902afafabb --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss @@ -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. + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts new file mode 100644 index 0000000000..c7e125f47f --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptCard"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx new file mode 100644 index 0000000000..6aa9181fa9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx @@ -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 ( + +

End user experience

+

+ 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. +

+

+ If there are any errors, the end user will be able to continue and will + be instructed to contact their IT department. +

+ install software preview +
+ ); +}; + +export default SetupExperienceScriptPreview; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss new file mode 100644 index 0000000000..b2fc995816 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts new file mode 100644 index 0000000000..8cf535c231 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptPreview"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx new file mode 100644 index 0000000000..91a6c9a28e --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx @@ -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 ( + + ); +}; + +export default SetupExperienceScriptUploader; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts new file mode 100644 index 0000000000..e4bd667f02 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptUploader"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts new file mode 100644 index 0000000000..143ecd15ab --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScript"; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 03a630f2f1..7731dc2673 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -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 diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index f8cae4461f..84a17efffd 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -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 => { @@ -331,6 +343,47 @@ const mdmService = { const url = `${MDM_COMMANDS_RESULTS}?command_uuid=${command_uuid}`; return sendRequest("GET", url); }, + + getSetupExperienceScript: ( + teamId: number + ): Promise => { + 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; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 5833a48653..877e00d2b9 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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}`,