) => {
+ 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 && (
+
+ )}
+
+ );
+};
+
+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.
+
+
+
+ );
+};
+
+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`,