implement UI for uploading, downloading, deleting macOS profiles (#9901)

relates to #9593 

Implements the UI for users to upload, download, and delete macos
profiles


![image](https://user-images.githubusercontent.com/1153709/219685914-6f44e77b-c2cb-47c3-897d-1ba137510fed.png)

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
- [ ] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez 2023-02-21 15:31:19 +00:00 committed by GitHub
parent 7f6a42e4ac
commit a11e2cce3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 572 additions and 16 deletions

View File

@ -0,0 +1 @@
- add UI implementation for users to upload, download, and deleted macos profiles.

View File

@ -0,0 +1,15 @@
import React from "react";
const Download = () => {
return (
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 1a1 1 0 0 0-2 0v7.365L5.64 7.232a1 1 0 1 0-1.28 1.536l3 2.5a1 1 0 0 0 1.28 0l3-2.5a1 1 0 1 0-1.28-1.536L9 8.365V1ZM2 9a1 1 0 1 0-2 0v6a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V9a1 1 0 1 0-2 0v5H2V9Z"
fill="#515774"
/>
</svg>
);
};
export default Download;

View File

@ -0,0 +1,41 @@
import React from "react";
const Profile = () => {
return (
<svg width="34" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#a)">
<path
d="M29.333 39.75H4.667a2.417 2.417 0 0 1-2.417-2.416V2.667A2.417 2.417 0 0 1 4.667.25h19.562c.64 0 1.255.255 1.709.708l5.104 5.105c.453.453.708 1.068.708 1.709v29.562a2.417 2.417 0 0 1-2.417 2.416Z"
fill="#fff"
stroke="#192147"
strokeWidth=".5"
/>
<g clipPath="url(#b)" fill="#8B8FA2">
<path d="M16.999 14.714a6.788 6.788 0 1 0 0 13.575 6.788 6.788 0 0 0 0-13.575Zm0 12.406a5.619 5.619 0 0 1-5.62-5.619 5.619 5.619 0 0 1 5.62-5.62 5.619 5.619 0 0 1 5.62 5.62 5.619 5.619 0 0 1-5.62 5.62Z" />
<path d="M16.999 23.207a1.708 1.708 0 1 0 0-3.416 1.708 1.708 0 0 0 0 3.416Z" />
<path d="M26.839 22.26c-.933-.417-.933-1.101 0-1.52.933-.419.87-.891-.14-1.054-1.007-.163-1.186-.821-.393-1.466.794-.644.61-1.087-.407-.982-1.017.105-1.359-.486-.759-1.314.598-.829.308-1.208-.646-.843-.954.366-1.439-.118-1.073-1.072.365-.955-.017-1.245-.843-.647-.826.598-1.419.255-1.314-.759.105-1.017-.338-1.198-.982-.407-.645.793-1.305.616-1.466-.393-.16-1.01-.635-1.07-1.054-.14-.417.933-1.1.933-1.52 0-.416-.933-.89-.87-1.054.14-.163 1.01-.821 1.186-1.466.393-.644-.794-1.086-.61-.981.407.104 1.017-.487 1.359-1.315.759-.829-.6-1.208-.308-.842.646.363.955-.12 1.439-1.073 1.073-.954-.363-1.245.016-.647.843.598.828.256 1.419-.759 1.314-1.017-.105-1.198.338-.407.982.794.645.617 1.306-.393 1.466-1.01.16-1.07.635-.14 1.054.931.419.933 1.1 0 1.52-.933.418-.87.89.14 1.054 1.01.163 1.187.821.393 1.465-.793.645-.61 1.087.407.983 1.017-.105 1.36.486.759 1.314-.598.828-.307 1.208.647.843.954-.364 1.438.118 1.073 1.072-.364.954.016 1.245.842.647.826-.598 1.42-.256 1.315.759-.105 1.016.337 1.198.981.407.645-.794 1.306-.617 1.466.393.163 1.01.636 1.07 1.054.14.417-.933 1.101-.933 1.52 0 .416.933.891.87 1.054-.14.163-1.01.821-1.187 1.466-.393.644.793 1.087.61.982-.407-.105-1.017.486-1.36 1.314-.759.829.6 1.208.307.843-.647-.363-.954.119-1.438 1.073-1.072.954.365 1.244-.017.646-.843-.598-.828-.256-1.42.759-1.314 1.017.104 1.198-.338.407-.983-.791-.644-.617-1.305.393-1.465 1.01-.163 1.07-.636.14-1.055Zm-9.84 7.306a8.067 8.067 0 1 1 0-16.132 8.068 8.068 0 0 1 0 16.134v-.002Z" />
<path d="m23.195 21.992-5.894-1.317-2.945-4.64-1.31.833 2.945 4.64-1.314 5.892 1.514.34 1.215-5.452 5.452 1.217.337-1.513Z" />
</g>
<path
d="M23.5.5h.834l.5 6.5 6.666.5v1h-6a2 2 0 0 1-2-2v-6Z"
fill="#C5C7D1"
/>
<path
d="M24.5.334v5.667c0 .736.597 1.333 1.333 1.333h6"
stroke="#192147"
strokeWidth=".5"
/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h34v40H0z" />
</clipPath>
<clipPath id="b">
<path fill="#fff" d="M6.5 11h21v21h-21z" />
</clipPath>
</defs>
</svg>
);
};
export default Profile;

View File

@ -1,6 +1,11 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
const TrashCan = () => {
interface ITrashCanProps {
color?: Colors;
}
const TrashCan = ({ color = "core-fleet-blue" }: ITrashCanProps) => {
return (
<svg
width="16"
@ -11,7 +16,7 @@ const TrashCan = () => {
>
<path
d="M13.25 2H10.5v-.5A1.5 1.5 0 0 0 9 0H7a1.5 1.5 0 0 0-1.5 1.5V2H2.75c-.69 0-1.25.56-1.25 1.25v1a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5v-1c0-.69-.56-1.25-1.25-1.25ZM6.5 1.5A.5.5 0 0 1 7 1h2a.5.5 0 0 1 .5.5V2h-3v-.5ZM2.449 5.75c-.09 0-.16.075-.156.164l.412 8.657A1.498 1.498 0 0 0 4.203 16h7.593c.802 0 1.46-.627 1.498-1.429l.413-8.657a.156.156 0 0 0-.156-.164H2.449ZM9.999 7a.5.5 0 1 1 1 0v6.5a.5.5 0 1 1-1 0V7ZM7.5 7a.5.5 0 1 1 1 0v6.5a.5.5 0 1 1-1 0V7ZM5 7a.5.5 0 1 1 1 0v6.5a.5.5 0 1 1-1 0V7Z"
fill="#6a67fe"
fill={COLORS[color]}
/>
</svg>
);

View File

@ -39,6 +39,8 @@ import Clipboard from "./Clipboard";
import Eye from "./Eye";
import Pencil from "./Pencil";
import TrashCan from "./TrashCan";
import Profile from "./Profile";
import Download from "./Download";
// a mapping of the usable names of icons to the icon source.
export const ICON_MAP = {
@ -80,6 +82,8 @@ export const ICON_MAP = {
"darwin-purple": ApplePurple,
"windows-blue": WindowsBlue,
"linux-green": LinuxGreen,
profile: Profile,
download: Download,
};
export type IconNames = keyof typeof ICON_MAP;

View File

@ -54,3 +54,16 @@ export interface IMdmSummaryResponse {
mobile_device_management_enrollment_status: IMdmStatus;
mobile_device_management_solution: IMdmSolution[] | null;
}
export interface IMdmProfile {
profile_id: number;
team_id: number;
name: string;
identifier: string;
created_at: string;
updated_at: string;
}
export interface IMdmProfilesResponse {
profiles: IMdmProfile[] | null;
}

View File

@ -0,0 +1,36 @@
import React from "react";
import { Params } from "react-router/lib/Router";
import SideNav from "pages/admin/components/SideNav";
import MAC_OS_SETTINGS_NAV_ITEMS from "./MacOSSettingsNavItems";
const baseClass = "mac-os-settings";
interface IMacOSSettingsProps {
params: Params;
}
const MacOSSettings = ({ params }: IMacOSSettingsProps) => {
const { section } = params;
const DEFAULT_SETTINGS_SECTION = MAC_OS_SETTINGS_NAV_ITEMS[0];
const currentFormSection =
MAC_OS_SETTINGS_NAV_ITEMS.find((item) => item.urlSection === section) ??
DEFAULT_SETTINGS_SECTION;
const CurrentCard = currentFormSection.Card;
return (
<div className={baseClass}>
<SideNav
className={`${baseClass}__side-nav`}
navItems={MAC_OS_SETTINGS_NAV_ITEMS}
activeItem={currentFormSection.urlSection}
CurrentCard={<CurrentCard />}
/>
</div>
);
};
export default MacOSSettings;

View File

@ -0,0 +1,15 @@
import PATHS from "router/paths";
import { ISideNavItem } from "pages/admin/components/SideNav/SideNav";
import CustomSettings from "./cards/CustomSettings";
const MAC_OS_SETTINGS_NAV_ITEMS: ISideNavItem<any>[] = [
{
title: "Custom settings",
urlSection: "custom-settings",
path: PATHS.CONTROLS_CUSTOM_SETTINGS,
Card: CustomSettings,
},
];
export default MAC_OS_SETTINGS_NAV_ITEMS;

View File

@ -0,0 +1,193 @@
import React, { useContext, useRef, useState } from "react";
import { useQuery } from "react-query";
import { AxiosResponse } from "axios";
import { format } from "date-fns";
import formatDistanceToNow from "date-fns/formatDistanceToNow";
import FileSaver from "file-saver";
import { IApiError } from "interfaces/errors";
import { IMdmProfile, IMdmProfilesResponse } from "interfaces/mdm";
import mdmAPI from "services/entities/mdm";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import CustomLink from "components/CustomLink";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers";
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
const baseClass = "custom-settings";
const CustomSettings = () => {
const { renderFlash } = useContext(NotificationContext);
const { currentTeam } = useContext(AppContext);
const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false);
const [showLoading, setShowLoading] = useState(false);
const selectedProfile = useRef<IMdmProfile | null>(null);
const {
data: profiles,
error: errorProfiles,
refetch: refectchProfiles,
} = useQuery<IMdmProfilesResponse, unknown, IMdmProfile[] | null>(
["profiles", currentTeam?.id],
() => mdmAPI.getProfiles(currentTeam?.id),
{
select: (data) => data.profiles,
refetchOnWindowFocus: false,
}
);
const onClickDownload = async (profile: IMdmProfile) => {
const fileContent = await mdmAPI.downloadProfile(profile.profile_id);
const formatDate = format(new Date(), "yyyy-MM-dd");
const filename = `${formatDate}_${profile.name}.mobileconfig`;
const file = new File([fileContent], filename);
FileSaver.saveAs(file);
};
const onClickDelete = (profile: IMdmProfile) => {
selectedProfile.current = profile;
setShowDeleteProfileModal(true);
};
const renderProfiles = () => {
if (!profiles || profiles.length === 0) return null;
const profileListItems = profiles.map((profile) => {
return (
<li key={profile.profile_id} className={`${baseClass}__profile`}>
<div className={`${baseClass}__profile-data`}>
<Icon name="profile" />
<div className={`${baseClass}__profile-info`}>
<span className={`${baseClass}__profile-name`}>
{profile.name}
</span>
<span className={`${baseClass}__profile-uploaded`}>
{`Uploaded ${formatDistanceToNow(
new Date(profile.created_at)
)} ago`}
</span>
</div>
</div>
<div className={`${baseClass}__profile-actions`}>
<Button
className={`${baseClass}__download-button`}
variant="text-icon"
onClick={() => onClickDownload(profile)}
>
<Icon name="download" />
</Button>
<Button
className={`${baseClass}__delete-button`}
variant="text-icon"
onClick={() => onClickDelete(profile)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
</div>
</li>
);
});
return (
<div className={`${baseClass}__profiles`}>
<div className={`${baseClass}__profiles-header`}>
<span>Configuration profile</span>
<span>Actions</span>
</div>
<ul className={`${baseClass}__profile-list`}>{profileListItems}</ul>
</div>
);
};
const onFileUpload = async (files: FileList | null) => {
setShowLoading(true);
if (!files || files.length === 0) return;
const file = files[0];
if (
file.type !== "application/x-apple-aspen-config" ||
!file.name.includes(".mobileconfig")
) {
renderFlash("error", UPLOAD_ERROR_MESSAGES.wrongType.message);
return;
}
try {
await mdmAPI.uploadProfile(file, currentTeam?.id);
refectchProfiles();
renderFlash("success", "Successfully uploaded!");
} catch (e) {
const error = e as AxiosResponse<IApiError>;
const errMessage = getErrorMessage(error);
renderFlash("error", errMessage);
} finally {
setShowLoading(false);
}
};
const onCancelDelete = () => {
selectedProfile.current = null;
setShowDeleteProfileModal(false);
};
const onDeleteProfile = async (profileId: number) => {
try {
await mdmAPI.deleteProfile(profileId);
refectchProfiles();
renderFlash("success", "Successfully deleted!");
} catch (e) {
renderFlash("error", "Couldnt delete. Please try again.");
} finally {
selectedProfile.current = null;
setShowDeleteProfileModal(false);
}
};
return (
<div className={baseClass}>
<h2>Custom Settings</h2>
<p className={`${baseClass}__description`}>
Create and upload configuration profiles to apply custom settings.{" "}
<CustomLink
newTab
text="Learn how"
url="https://fleetdm.com/docs/controls#macos-settings"
/>
</p>
{renderProfiles()}
<div className={`${baseClass}__profile-uploader`}>
<Icon name="profile" />
<p>Configuration profile (.mobileconfig)</p>
<Button isLoading={showLoading}>
<label htmlFor="upload-profile">Upload</label>
</Button>
<input
accept=".mobileconfig,application/x-apple-aspen-config"
id="upload-profile"
type="file"
onChange={(e) => onFileUpload(e.target.files)}
/>
</div>
{showDeleteProfileModal && selectedProfile.current && (
<DeleteProfileModal
profileName={selectedProfile.current?.name}
profileId={selectedProfile.current?.profile_id}
onCancel={onCancelDelete}
onDelete={onDeleteProfile}
/>
)}
</div>
);
};
export default CustomSettings;

View File

@ -0,0 +1,81 @@
.custom-settings {
h2 {
padding-bottom: $pad-small;
font-size: $medium;
font-weight: $regular;
color: $core-fleet-black;
border-bottom: solid 1px $ui-fleet-black-10;
}
&__description {
font-size: $x-small;
margin: $pad-xxlarge 0;
}
&__profiles-header {
padding: $pad-medium $pad-large;
display: flex;
justify-content: space-between;
font-size: $x-small;
font-weight: $bold;
border-bottom: 1px solid $ui-fleet-black-10;
}
&__profile-list {
list-style: none;
padding: 0;
margin: 0;
}
&__profile {
padding: $pad-medium $pad-large;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid $ui-fleet-black-10;
}
&__profile-data {
display: flex;
align-items: center;
}
&__profile-info {
margin-left: $pad-medium;
display: flex;
flex-direction: column;
}
&__profile-name {
font-size: $x-small;
}
&__profile-uploaded {
font-size: $xx-small;
}
&__download-button, &__delete-button {
width: 40px;
height: 40px;
}
&__download-button {
margin-right: $pad-medium;
}
&__profile-uploader {
display: flex;
flex-direction: column;
align-items: center;
border-radius: $border-radius;
background-color: $ui-fleet-blue-10;
border: 1px solid $ui-fleet-black-10;
padding: $pad-xlarge $pad-large;
font-size: $x-small;
margin-top: $pad-xxlarge;
input {
display: none;
}
}
}

View File

@ -0,0 +1,65 @@
import React, { useContext } from "react";
import { AppContext } from "context/app";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
interface DeleteProfileModalProps {
profileName: string;
profileId: number;
onCancel: () => void;
onDelete: (profileId: number) => void;
}
const baseClass = "delete-profile-modal";
const generateMessageSuffix = (isPremiumTier?: boolean, teamId?: number) => {
if (!isPremiumTier) {
return "";
}
return teamId ? " assigned to this team" : " with no team";
};
const DeleteProfileModal = ({
profileName,
profileId,
onCancel,
onDelete,
}: DeleteProfileModalProps) => {
const { isPremiumTier, currentTeam } = useContext(AppContext);
const messageSuffix = generateMessageSuffix(isPremiumTier, currentTeam?.id);
return (
<Modal
className={baseClass}
title={"Delete configuration profile"}
onExit={onCancel}
onEnter={() => onDelete(profileId)}
>
<>
<p>
This action will delete configuration profile{" "}
<span className={`${baseClass}__profile-name`}>{profileName}</span>{" "}
from all macOS hosts{messageSuffix}.
</p>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={() => onDelete(profileId)}
variant="alert"
className="delete-loading"
>
Delete
</Button>
<Button onClick={onCancel} variant="inverse-alert">
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default DeleteProfileModal;

View File

@ -0,0 +1,5 @@
.delete-profile-modal {
&__profile-name {
font-weight: $bold;
}
}

View File

@ -0,0 +1,52 @@
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
export const UPLOAD_ERROR_MESSAGES = {
wrongType: {
condition: () => false,
message: "Couldnt upload. The file should be a .mobileconfig file.",
},
identifierExists: {
condition: (reason: string) =>
reason.includes("MDMAppleConfigProfile.PayloadIdentifier"),
message:
"Couldnt upload. A configuration profile with this identifier (PayloadIdentifier) already exists.",
},
nameExists: {
condition: (reason: string) => reason.includes("PayloadDisplayName"),
message:
"Couldnt upload. A configuration profile with this name (PayloadDisplayName) already exists.",
},
encrypted: {
condition: (reason: string) => reason.includes("encrypted"),
message: "Couldnt upload. The file should be unencrypted.",
},
validXML: {
condition: (reason: string) => reason.includes("parsing XML"),
message: "Couldnt upload. The file should include valid XML.",
},
fileVault: {
condition: (reason: string) =>
reason.includes("unsupported PayloadType(s): com.apple.MCX.FileVault2"),
message:
"Couldnt upload. The configuration profile cant include FileVault settings. To control these settings, go to Disk encryption.",
},
default: {
condition: () => false,
message: "Couldnt upload. Please try again.",
},
};
export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
const apiReason = err.data.errors[0].reason;
const error = Object.values(UPLOAD_ERROR_MESSAGES).find((errType) =>
errType.condition(apiReason)
);
if (!error) {
return UPLOAD_ERROR_MESSAGES.default.message;
}
return error.message;
};

View File

@ -0,0 +1 @@
export { default } from "./CustomSettings";

View File

@ -0,0 +1 @@
export { default } from "./MacOSSettings";

View File

@ -48,6 +48,7 @@ import ManageControlsPage from "pages/ManageControlsPage/ManageControlsPage";
import MembersPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage";
import AgentOptionsPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/AgentOptionsPage";
import MacOSUpdates from "pages/MacOSUpdates";
import MacOSSettings from "pages/ManageControlsPage/MacOSSettings";
import PATHS from "router/paths";
import AppProvider from "context/app";
@ -79,17 +80,6 @@ const AppWrapper = ({ children, location }: IAppWrapperProps) => (
</AppProvider>
);
const MacSettingsPage = () => {
return (
<div>
<EmptyTable
header="Coming soon"
info="The ability to store disk encryption keys and customize macOS settings are currently in development."
/>
</div>
);
};
const routes = (
<Router history={browserHistory}>
<Route path={PATHS.ROOT} component={AppWrapper}>
@ -179,7 +169,8 @@ const routes = (
<IndexRedirect to={"mac-os-updates"} />
<Route component={ManageControlsPage}>
<Route path="mac-os-updates" component={MacOSUpdates} />
<Route path="mac-settings" component={MacSettingsPage} />
<Route path="mac-settings" component={MacOSSettings} />
<Route path="mac-settings/:section" component={MacOSSettings} />
</Route>
</Route>
</Route>

View File

@ -1,4 +1,3 @@
import { IHost } from "../interfaces/host";
import { IQuery } from "../interfaces/query";
import { IPolicy } from "../interfaces/policy";
import URL_PREFIX from "./url_prefix";
@ -8,6 +7,7 @@ export default {
CONTROLS: `${URL_PREFIX}/controls`,
CONTROLS_MAC_OS_UPDATES: `${URL_PREFIX}/controls/mac-os-updates`,
CONTROLS_MAC_SETTINGS: `${URL_PREFIX}/controls/mac-settings`,
CONTROLS_CUSTOM_SETTINGS: `${URL_PREFIX}/controls/mac-settings/custom-settings`,
DASHBOARD: `${URL_PREFIX}/dashboard`,
DASHBOARD_LINUX: `${URL_PREFIX}/dashboard/linux`,
DASHBOARD_MAC: `${URL_PREFIX}/dashboard/mac`,

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
export default {
downloadDeviceUserEnrollmentProfile: (token: string) => {
@ -25,4 +26,39 @@ export default {
organization,
});
},
getProfiles: (teamId?: number) => {
const { MDM_PROFILES } = endpoints;
let path = MDM_PROFILES;
if (teamId) {
path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`;
}
return sendRequest("GET", path);
},
uploadProfile: (file: File, teamId?: number) => {
const { MDM_PROFILES } = endpoints;
const formData = new FormData();
formData.append("profile", file);
if (teamId) {
formData.append("team_id", teamId.toString());
}
return sendRequest("POST", MDM_PROFILES, formData);
},
downloadProfile: (profileId: number) => {
const { MDM_PROFILE } = endpoints;
return sendRequest("GET", MDM_PROFILE(profileId));
},
deleteProfile: (profileId: number) => {
const { MDM_PROFILE } = endpoints;
return sendRequest("DELETE", MDM_PROFILE(profileId));
},
};

View File

@ -34,12 +34,13 @@ export default {
LOGIN: `/${API_VERSION}/fleet/login`,
LOGOUT: `/${API_VERSION}/fleet/logout`,
MACADMINS: `/${API_VERSION}/fleet/macadmins`,
// TODO: Clean up MDM endpoints to be consistent and up to date
MDM_APPLE: `/${API_VERSION}/fleet/mdm/apple`,
MDM_APPLE_BM: `/${API_VERSION}/fleet/mdm/apple_bm`,
MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple/dep/key_pair`,
MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`,
MDM_REQUEST_CSR: `/${API_VERSION}/fleet/mdm/apple/request_csr`,
MDM_PROFILES: `/${API_VERSION}/fleet/mdm/apple/profiles`,
MDM_PROFILE: (id: number) => `/${API_VERSION}/fleet/mdm/apple/profiles/${id}`,
// Should below 2 endpoints be consistent?
HOST_MDM: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/mdm`,
HOST_MDM_UNENROLL: (id: number) =>