From a292e704de49cdcb9c42214deb21feb46ff0ca10 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 26 Mar 2024 14:46:33 +0000 Subject: [PATCH] add startup assistant to the UI (#17731) relates to #9147 add the setup assistant page to the UI. This includes: - new setup assistant page - uploaded profile and release device manually form - preview for setup assistant flow - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers --- frontend/__mocks__/configMock.ts | 1 + frontend/components/Card/Card.tsx | 14 +- frontend/components/Card/_styles.scss | 22 +++ .../components/FileUploader/FileUploader.tsx | 7 +- frontend/components/FileUploader/_styles.scss | 4 +- frontend/interfaces/config.ts | 1 + frontend/interfaces/team.ts | 3 +- .../SetupExperienceNavItems.tsx | 7 + .../BootstrapPackagePreview/_styles.scss | 4 +- .../cards/SetupAssistant/SetupAssistant.tsx | 142 ++++++++++++++++++ .../cards/SetupAssistant/_styles.scss | 25 +++ .../AdvancedOptionsForm.tsx | 71 +++++++++ .../AdvancedOptionsForm/_styles.scss | 10 ++ .../components/AdvancedOptionsForm/index.ts | 1 + .../DeleteAutoEnrollmentProfile.tsx | 59 ++++++++ .../DeleteAutoEnrollmentProfile/index.ts | 1 + .../SetupAssistantPreview.tsx | 35 +++++ .../SetupAssistantPreview/_styles.scss | 16 ++ .../components/SetupAssistantPreview/index.ts | 1 + .../SetupAssistantProfileCard.tsx | 64 ++++++++ .../SetupAssistantProfileCard/_styles.scss | 30 ++++ .../SetupAssistantProfileCard/index.ts | 1 + .../SetupAssistantProfileUploader.tsx | 62 ++++++++ .../SetupAssistantProfileUploader/helpers.ts | 13 ++ .../SetupAssistantProfileUploader/index.ts | 1 + .../cards/SetupAssistant/index.ts | 1 + frontend/router/paths.ts | 1 + frontend/services/entities/config.ts | 4 +- frontend/services/entities/mdm.ts | 76 ++++++++++ frontend/services/entities/teams.ts | 10 ++ .../date_format/date_format.tests.ts | 13 ++ frontend/utilities/date_format/index.ts | 6 + frontend/utilities/endpoints.ts | 2 + 33 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts create mode 100644 frontend/utilities/date_format/date_format.tests.ts create mode 100644 frontend/utilities/date_format/index.ts diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 26b996662a..5d56609f41 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -145,6 +145,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { bootstrap_package: "", enable_end_user_authentication: false, macos_setup_assistant: null, + enable_release_device_manually: false, }, macos_migration: { enable: false, diff --git a/frontend/components/Card/Card.tsx b/frontend/components/Card/Card.tsx index 08d3c777d7..0e394250ea 100644 --- a/frontend/components/Card/Card.tsx +++ b/frontend/components/Card/Card.tsx @@ -8,14 +8,20 @@ type CardColor = "white" | "gray" | "purple" | "yellow"; interface ICardProps { children?: React.ReactNode; - /** The size of the border radius. Defaults to `small` */ + /** The size of the border radius. Defaults to `small`. */ borderRadiusSize?: BorderRadiusSize; /** Includes the card shadows. Defaults to `false` */ includeShadow?: boolean; /** The color of the card. Defaults to `white` */ color?: CardColor; className?: string; - /** Increases to 40px padding. Defaults to `false` */ + /** The size of the padding around the content of the card. Defaults to `large`. + * + * These correspond to the padding sizes in the design system. Look at `padding.scss` for values */ + paddingSize?: "small" | "medium" | "large" | "xlarge" | "xxlarge"; + /** NOTE: DEPRICATED. Use `paddingSize` prop instead. + * + * Increases to 40px padding. Defaults to `false` */ largePadding?: boolean; } @@ -30,12 +36,16 @@ const Card = ({ color = "white", className, largePadding = false, + paddingSize = "large", }: ICardProps) => { const classNames = classnames( baseClass, `${baseClass}__${color}`, `${baseClass}__radius-${borderRadiusSize}`, { + // TODO: simplify this when we've replaced largePadding prop with paddingSize + [`${baseClass}__padding-${paddingSize}`]: + !largePadding && paddingSize !== undefined, [`${baseClass}__shadow`]: includeShadow, [`${baseClass}__large-padding`]: largePadding, }, diff --git a/frontend/components/Card/_styles.scss b/frontend/components/Card/_styles.scss index 4fec9ffc64..0fac0a084a 100644 --- a/frontend/components/Card/_styles.scss +++ b/frontend/components/Card/_styles.scss @@ -22,7 +22,29 @@ box-shadow: $box-shadow; } + &__padding-small { + padding: $pad-small; + } + + &__padding-medium { + padding: $pad-medium; + } + + &__padding-large { + padding: $pad-large; + } + + &__padding-xlarge { + padding: $pad-xlarge; + } + + &__padding-xxlarge { + padding: $pad-xxlarge; + } + // 40px padding + // TODO: remove when we've replaced all instances of largePadding with + // paddingSize prop &__large-padding { padding: $pad-xxlarge; } diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index 8f139d1730..52ab307656 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -32,6 +32,8 @@ interface IFileUploaderProps { * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */ accept?: string; + /** The text to display on the upload button */ + buttonMessage?: string; className?: string; onFileUpload: (files: FileList | null) => void; } @@ -45,6 +47,7 @@ const FileUploader = ({ additionalInfo, isLoading = false, accept, + buttonMessage = "Upload", className, onFileUpload, }: IFileUploaderProps) => { @@ -73,11 +76,11 @@ const FileUploader = ({ variant="brand" isLoading={isLoading} > - + { onFileUpload(e.target.files); diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss index 1cdb033ce3..4a6835d3ec 100644 --- a/frontend/components/FileUploader/_styles.scss +++ b/frontend/components/FileUploader/_styles.scss @@ -30,12 +30,12 @@ &__upload-button { margin-top: 8px; + // we handle the padding in the label so the entire button is clickable padding: 0; } label { - height: 36px; - width: 78.2667px; + padding: $pad-small $pad-medium; display: flex; align-items: center; justify-content: center; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 1df44de33d..f0bf37203f 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -48,6 +48,7 @@ export interface IMdmConfig { bootstrap_package: string | null; enable_end_user_authentication: boolean; macos_setup_assistant: string | null; + enable_release_device_manually: boolean | null; }; macos_migration: IMacOsMigrationSettings; windows_updates: { diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 435075902a..3569ec26c9 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -56,7 +56,8 @@ export interface ITeam extends ITeamSummary { macos_setup: { bootstrap_package: string | null; enable_end_user_authentication: boolean; - macos_setup_assistant: string | null; // TODO: types? + macos_setup_assistant: string | null; + enable_release_device_manually: boolean | null; }; windows_updates: { deadline_days: number | null; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx index ca9021ac9d..f8fed7e832 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx @@ -4,6 +4,7 @@ import { ISideNavItem } from "pages/admin/components/SideNav/SideNav"; import EndUserAuthentication from "./cards/EndUserAuthentication/EndUserAuthentication"; import BootstrapPackage from "./cards/BootstrapPackage"; +import SetupAssistant from "./cards/SetupAssistant"; interface ISetupExperienceCardProps { currentTeamId?: number; @@ -25,6 +26,12 @@ const SETUP_EXPERIENCE_NAV_ITEMS: ISideNavItem< path: PATHS.CONTROLS_BOOTSTRAP_PACKAGE, Card: BootstrapPackage, }, + { + title: "Setup assistant", + urlSection: "setup-assistant", + path: PATHS.CONTROLS_SETUP_ASSITANT, + Card: SetupAssistant, + }, ]; export default SETUP_EXPERIENCE_NAV_ITEMS; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss index c5702ed0db..03d790b2f6 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss @@ -6,7 +6,9 @@ font-size: $x-small; h2 { - font-size: $x-small; // needed to override global h2 style + margin: 0; + font-size: $small; + font-weight: normal; } &__preview-img { diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx new file mode 100644 index 0000000000..f670a8e710 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx @@ -0,0 +1,142 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import { IConfig } from "interfaces/config"; +import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team"; +import configAPI from "services/entities/config"; +import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; +import mdmAPI, { + IAppleSetupEnrollmentProfileResponse, +} from "services/entities/mdm"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import SectionHeader from "components/SectionHeader"; +import Spinner from "components/Spinner"; +import CustomLink from "components/CustomLink"; + +import SetupAssistantPreview from "./components/SetupAssistantPreview"; +import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUploader"; +import SetuAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard"; +import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile"; +import AdvancedOptionsForm from "./components/AdvancedOptionsForm"; + +const baseClass = "setup-assistant"; + +interface ISetupAssistantProps { + currentTeamId: number; +} + +const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => { + const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false); + + const { data: globalConfig, isLoading: isLoadingGlobalConfig } = useQuery< + IConfig, + Error + >(["config", currentTeamId], () => configAPI.loadAll(), { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + enabled: currentTeamId === API_NO_TEAM_ID, + }); + + const { data: teamConfig, isLoading: isLoadingTeamConfig } = useQuery< + ILoadTeamResponse, + Error, + ITeamConfig + >(["team", currentTeamId], () => teamsAPI.load(currentTeamId), { + ...DEFAULT_USE_QUERY_OPTIONS, + refetchOnWindowFocus: false, + retry: false, + enabled: currentTeamId !== API_NO_TEAM_ID, + select: (res) => res.team, + }); + + const { + data: enrollmentProfileData, + isLoading: isLoadingEnrollmentProfile, + error: enrollmentProfileError, + refetch: refetchEnrollmentProfile, + } = useQuery( + ["enrollment_profile", currentTeamId], + () => mdmAPI.getSetupEnrollmentProfile(currentTeamId), + { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + } + ); + + const getReleaseDeviceSetting = () => { + if (currentTeamId === API_NO_TEAM_ID) { + return ( + globalConfig?.mdm.macos_setup.enable_release_device_manually || false + ); + } + return teamConfig?.mdm?.macos_setup.enable_release_device_manually || false; + }; + + const onUpload = () => { + refetchEnrollmentProfile(); + }; + + const onDelete = () => { + setShowDeleteProfileModal(false); + refetchEnrollmentProfile(); + }; + + const defaultReleaseDeviceSetting = getReleaseDeviceSetting(); + + const isLoading = + isLoadingGlobalConfig || isLoadingTeamConfig || isLoadingEnrollmentProfile; + const enrollmentProfileNotFound = enrollmentProfileError?.status === 404; + + return ( +
+ + {isLoading ? ( + + ) : ( +
+
+

+ Add an automatic enrollment profile to customize the macOS Setup + Assistant. + +

+ {enrollmentProfileNotFound || !enrollmentProfileData ? ( + + ) : ( + setShowDeleteProfileModal(true)} + /> + )} + +
+
+ +
+
+ )} + {showDeleteProfileModal && ( + setShowDeleteProfileModal(false)} + /> + )} +
+ ); +}; + +export default SetupAssistant; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss new file mode 100644 index 0000000000..dc2e054fb3 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss @@ -0,0 +1,25 @@ +.setup-assistant { + &__content { + max-width: $break-xxl; + margin: 0 auto; + display: flex; + justify-content: space-between; + gap: $pad-xxlarge; + } + + &__upload-container { + display: flex; + flex-direction: column; + gap: $pad-large; + } + + &__section-description { + margin: 0; + } + + @media (max-width: $break-md) { + &__content { + flex-direction: column; + } + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx new file mode 100644 index 0000000000..3e3c94a797 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useState } from "react"; + +import mdmAPI from "services/entities/mdm"; + +import TooltipWrapper from "components/TooltipWrapper"; +import Checkbox from "components/forms/fields/Checkbox"; +import Button from "components/buttons/Button"; +import { NotificationContext } from "context/notification"; +import RevealButton from "components/buttons/RevealButton"; + +const baseClass = "advanced-options-form"; + +interface IAdvancedOptionsFormProps { + currentTeamId: number; + defaultReleaseDevice: boolean; +} + +const AdvancedOptionsForm = ({ + currentTeamId, + defaultReleaseDevice, +}: IAdvancedOptionsFormProps) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + const [releaseDevice, setReleaseDevice] = useState(defaultReleaseDevice); + const { renderFlash } = useContext(NotificationContext); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await mdmAPI.updateReleaseDeviceSetting(currentTeamId, releaseDevice); + renderFlash("success", "Successfully updated."); + } catch { + renderFlash("error", "Something went wrong. Please try again."); + } + }; + + const tooltip = ( + <> + When enabled, you're responsible for sending the DeviceConfigured + command. (Default: Off) + + ); + + return ( +
+ setShowAdvancedOptions(!showAdvancedOptions)} + /> + {showAdvancedOptions && ( +
+ setReleaseDevice(!releaseDevice)} + > + + Release device manually + + + +
+ )} +
+ ); +}; + +export default AdvancedOptionsForm; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss new file mode 100644 index 0000000000..b08bafb01e --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss @@ -0,0 +1,10 @@ +.advanced-options-form { + display: flex; + flex-direction: column; + gap: $pad-large; + + &__accordion-title { + // use this so we dont center the text. + justify-content: normal; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts new file mode 100644 index 0000000000..1b538503ff --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts @@ -0,0 +1 @@ +export { default } from "./AdvancedOptionsForm"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx new file mode 100644 index 0000000000..0ab9ef7026 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx @@ -0,0 +1,59 @@ +import React, { useContext } from "react"; + +import mdmAPI from "services/entities/mdm"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import { NotificationContext } from "context/notification"; + +interface DeleteAutoEnrollProfileProps { + currentTeamId: number; + onCancel: () => void; + onDelete: () => void; +} + +const baseClass = "delete-auto-enrollment-profile-modal"; + +const DeleteAutoEnrollProfile = ({ + currentTeamId, + onCancel, + onDelete, +}: DeleteAutoEnrollProfileProps) => { + const { renderFlash } = useContext(NotificationContext); + + const handleDelete = async () => { + try { + await mdmAPI.deleteSetupEnrollmentProfile(currentTeamId); + renderFlash("success", "Successfully deleted!"); + } catch { + renderFlash("error", "Couldn’t delete. Please try again."); + } + onDelete(); + }; + + return ( + + <> +

Delete the automatic enrollment profile to upload a new one.

+

+ Without an automatic enrollment profile, new macOS hosts will + automatically enroll with the default setup settings. +

+
+ + +
+ +
+ ); +}; + +export default DeleteAutoEnrollProfile; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts new file mode 100644 index 0000000000..dfd08b7064 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteAutoEnrollmentProfile"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx new file mode 100644 index 0000000000..9e080644d6 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import Card from "components/Card"; + +import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif"; + +const baseClass = "setup-assistant-preview"; + +const SetupAssistantPreview = () => { + return ( + +

End user experience

+

+ After the end user continues past the Remote Management screen, + macOS Setup Assistant displays several screens by default. +

+

+ By adding an automatic enrollment profile you can customize which + screens are displayed and more. +

+ OS setup preview +
+ ); +}; + +export default SetupAssistantPreview; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss new file mode 100644 index 0000000000..07f7e9b797 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss @@ -0,0 +1,16 @@ +.setup-assistant-preview { + font-size: $x-small; + + h2 { + margin: 0; + font-size: $small; + font-weight: normal; + } + + &__preview-img { + margin-top: $pad-xxlarge; + width: 100%; + display: block; + margin: 40px auto 0; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts new file mode 100644 index 0000000000..ecdc67cdf4 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantPreview"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx new file mode 100644 index 0000000000..8d6d64dd41 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import FileSaver from "file-saver"; + +import { uploadedFromNow } from "utilities/date_format"; + +import Icon from "components/Icon"; +import Card from "components/Card"; +import Graphic from "components/Graphic"; +import Button from "components/buttons/Button"; +import { IAppleSetupEnrollmentProfileResponse } from "services/entities/mdm"; + +const baseClass = "setup-assistant-profile-card"; +interface ISetupAssistantProfileCardProps { + profile: IAppleSetupEnrollmentProfileResponse; + onDelete: () => void; +} + +const SetupAssistantProfileCard = ({ + profile, + onDelete, +}: ISetupAssistantProfileCardProps) => { + const onDownload = () => { + const date = new Date(); + const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${ + profile.name + }`; + const file = new global.window.File( + [JSON.stringify(profile.enrollment_profile)], + filename + ); + + FileSaver.saveAs(file); + }; + + return ( + + +
+ {profile.name} + + {uploadedFromNow(profile.uploaded_at)} + +
+
+ + +
+
+ ); +}; + +export default SetupAssistantProfileCard; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss new file mode 100644 index 0000000000..7d8d4bbc63 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss @@ -0,0 +1,30 @@ +.setup-assistant-profile-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/SetupAssistant/components/SetupAssistantProfileCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts new file mode 100644 index 0000000000..00e2af3c93 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantProfileCard"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx new file mode 100644 index 0000000000..936e9f58fa --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx @@ -0,0 +1,62 @@ +import React, { useContext, useState } from "react"; +import { AxiosResponse } from "axios"; + +import { IApiError } from "interfaces/errors"; +import { NotificationContext } from "context/notification"; +import mdmAPI from "services/entities/mdm"; + +import FileUploader from "components/FileUploader"; + +import { getErrorMessage } from "./helpers"; + +const baseClass = "setup-assistant-profile-uploader"; + +interface ISetupAssistantProfileUploaderProps { + currentTeamId: number; + onUpload: () => void; +} + +const SetupAssistantProfileUploader = ({ + currentTeamId, + onUpload, +}: ISetupAssistantProfileUploaderProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showLoading, setShowLoading] = useState(false); + + const onUploadFile = async (files: FileList | null) => { + setShowLoading(true); + + if (!files || files.length === 0) { + setShowLoading(false); + return; + } + + const file = files[0]; + + try { + await mdmAPI.uploadSetupEnrollmentProfile(file, currentTeamId); + renderFlash("success", "Successfully uploaded!"); + onUpload(); + } catch (e) { + const error = e as AxiosResponse; + const errMessage = getErrorMessage(error); + renderFlash("error", errMessage); + } finally { + setShowLoading(false); + } + }; + + return ( + + ); +}; + +export default SetupAssistantProfileUploader; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts new file mode 100644 index 0000000000..bd7538a73a --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts @@ -0,0 +1,13 @@ +import { getErrorReason } from "interfaces/errors"; + +const UPLOAD_ERROR_MESSAGES = { + default: { + message: "Couldn't upload. Please try again.", + }, +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (err: unknown) => { + if (typeof err === "string") return err; + return getErrorReason(err) || UPLOAD_ERROR_MESSAGES.default.message; +}; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts new file mode 100644 index 0000000000..f27221879b --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantProfileUploader"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts new file mode 100644 index 0000000000..473124c799 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistant"; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 114e42dd8e..55ff6d8c62 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -14,6 +14,7 @@ export default { CONTROLS_SETUP_EXPERIENCE: `${URL_PREFIX}/controls/setup-experience`, CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`, CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`, + CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`, CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`, DASHBOARD: `${URL_PREFIX}/dashboard`, diff --git a/frontend/services/entities/config.ts b/frontend/services/entities/config.ts index 78af25563d..685826a7dc 100644 --- a/frontend/services/entities/config.ts +++ b/frontend/services/entities/config.ts @@ -57,8 +57,8 @@ export default { }, /** - * updateMDMConfig is a special case of update that is used to update the MDM - * config. + * updateMDMConfig is a special case of update that is used to update the app + * MDM config. * * If the request fails and `skipParseError` is `true`, the caller is * responsible for verifying that the value of the rejected promise is an AxiosError diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 29a8e5fab9..90101a1135 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -4,6 +4,7 @@ import { IMdmProfile, MdmProfileStatus, } from "interfaces/mdm"; +import { API_NO_TEAM_ID } from "interfaces/team"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; @@ -46,6 +47,19 @@ export interface IUploadProfileApiParams { labels?: string[]; } +interface IUpdateSetupExperienceBody { + team_id?: number; + enable_release_device_manually: boolean; +} + +export interface IAppleSetupEnrollmentProfileResponse { + team_id: number | null; + name: string; + uploaded_at: string; + // enrollment profile is an object with keys found here https://developer.apple.com/documentation/devicemanagement/profile. + enrollment_profile: Record; +} + const mdmService = { downloadDeviceUserEnrollmentProfile: (token: string) => { const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; @@ -226,6 +240,68 @@ const mdmService = { enable_end_user_authentication: isEnabled, }); }, + + updateReleaseDeviceSetting: (teamId: number, isEnabled: boolean) => { + const { MDM_SETUP_EXPERIENCE } = endpoints; + + const body: IUpdateSetupExperienceBody = { + enable_release_device_manually: isEnabled, + }; + + if (teamId !== API_NO_TEAM_ID) { + body.team_id = teamId; + } + + return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body); + }, + getSetupEnrollmentProfile: (teamId?: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + if (!teamId || teamId === API_NO_TEAM_ID) { + return sendRequest("GET", MDM_APPLE_SETUP_ENROLLMENT_PROFILE); + } + + const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams( + { team_id: teamId } + )}`; + return sendRequest("GET", path); + }, + uploadSetupEnrollmentProfile: (file: File, teamId: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + + const reader = new FileReader(); + reader.readAsText(file); + + return new Promise((resolve, reject) => { + reader.addEventListener("load", () => { + try { + const body: Record = { + name: file.name, + enrollment_profile: JSON.parse(reader.result as string), + }; + if (teamId !== API_NO_TEAM_ID) { + body.team_id = teamId; + } + resolve( + sendRequest("POST", MDM_APPLE_SETUP_ENROLLMENT_PROFILE, body) + ); + } catch { + // catches invalid JSON + reject("Couldn’t upload. The file should include valid JSON."); + } + }); + }); + }, + deleteSetupEnrollmentProfile: (teamId: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + if (teamId === API_NO_TEAM_ID) { + return sendRequest("DELETE", MDM_APPLE_SETUP_ENROLLMENT_PROFILE); + } + + const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams( + { team_id: teamId } + )}`; + return sendRequest("DELETE", path); + }, }; export default mdmService; diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 3c7e1a2619..eced1b9c6d 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -139,6 +139,16 @@ export default { return sendRequest("PATCH", path, requestBody); }, + + /** + * updates the team config. This can take any partial data that is in the team config. + */ + updateConfig: (data: any, teamId: number): Promise => { + const { TEAMS } = endpoints; + const path = `${TEAMS}/${teamId}`; + return sendRequest("PATCH", path, data); + }, + addUsers: (teamId: number | undefined, newUsers: INewTeamUsersBody) => { if (!teamId || teamId <= API_NO_TEAM_ID) { return Promise.reject( diff --git a/frontend/utilities/date_format/date_format.tests.ts b/frontend/utilities/date_format/date_format.tests.ts new file mode 100644 index 0000000000..102f34d048 --- /dev/null +++ b/frontend/utilities/date_format/date_format.tests.ts @@ -0,0 +1,13 @@ +import { uploadedFromNow } from "."; + +describe("date_format", () => { + describe("uploadedFromNow util", () => { + it("returns an user friendly uploaded at message", () => { + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - 2); + const twoDaysAgo = currentDate.toISOString(); + + expect(uploadedFromNow(twoDaysAgo)).toEqual("Uploaded 2 days ago"); + }); + }); +}); diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts new file mode 100644 index 0000000000..356eaef6f2 --- /dev/null +++ b/frontend/utilities/date_format/index.ts @@ -0,0 +1,6 @@ +import { formatDistanceToNow } from "date-fns"; + +// eslint-disable-next-line import/prefer-default-export +export const uploadedFromNow = (date: string) => { + return `Uploaded ${formatDistanceToNow(new Date(date))} ago`; +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index b54ec4fd97..58c2b4c5db 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -79,11 +79,13 @@ export default { } return `/api/mdm/apple/enroll?${query}`; }, + MDM_APPLE_SETUP_ENROLLMENT_PROFILE: `/${API_VERSION}/fleet/mdm/apple/enrollment_profile`, MDM_BOOTSTRAP_PACKAGE_METADATA: (teamId: number) => `/${API_VERSION}/fleet/mdm/bootstrap/${teamId}/metadata`, 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`,