UI: 9274 unenroll mdm modal (#9539)

# Addresses #9274 

https://www.loom.com/share/2edd946cbd424af2b960801cc505ac85

## Button and permissions:
- no permission, enrolled, online: <img width="1131" alt="no permission,
enrolled, online"
src="https://user-images.githubusercontent.com/61553566/215197330-abc1606d-bf0a-44ec-b2de-2ef687bd529b.png">
- permission, enrolled, online: <img width="1131" alt="permission,
enrolled, online"
src="https://user-images.githubusercontent.com/61553566/215197443-a1353b9b-10dd-408b-8295-56029f2df4c3.png">
- permission, enrolled, offline: <img width="1131" alt="permission,
enrolled, offline"
src="https://user-images.githubusercontent.com/61553566/215197544-b2a997a7-09e5-4f8a-b723-af587b61a90d.png">
- not enrolled: <img width="1131" alt="not enrolled"
src="https://user-images.githubusercontent.com/61553566/215197630-87f99cb3-63a9-45ce-bc85-57a45d54cae0.png">

## Modal
- <img width="672" alt="modal"
src="https://user-images.githubusercontent.com/61553566/215214640-96670a23-d927-4213-a8fa-89411279c075.png">
- <img width="672" alt="Screenshot 2023-01-27 at 2 12 42 PM"
src="https://user-images.githubusercontent.com/61553566/215215098-40d29556-3b73-4f52-a4ae-cc8b09122f5d.png">
- <img width="672" alt="Screenshot 2023-01-27 at 2 17 48 PM"
src="https://user-images.githubusercontent.com/61553566/215216304-b9362b13-f37f-4454-81b5-423f6fc72280.png">
- <img width="787" alt="success-shot"
src="https://user-images.githubusercontent.com/61553566/215236373-be7b1970-662d-47e6-ac59-f51eff344fcd.png">




# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/` 
- [x] Updated test inventory
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2023-01-30 15:59:02 -08:00 committed by GitHub
parent 180f7691ce
commit f12780df45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 171 additions and 31 deletions

View File

@ -0,0 +1 @@
- Implemented the ability for an authorized user to unenroll a host from MDM on its host details page. The host must be enrolled in MDM and online.

View File

@ -12,7 +12,6 @@
&__inner {
display: flex;
flex-direction: row;
margin: $pad-xxxlarge 0;
}
.info {

View File

@ -7,7 +7,7 @@ import softwareInterface, { ISoftware } from "./software";
import hostQueryResult from "./campaign";
import queryStatsInterface, { IQueryStats } from "./query_stats";
import { ILicense } from "./config";
import { MdmStatus } from "./mdm";
import { MdmEnrollmentStatus } from "./mdm";
export default PropTypes.shape({
created_at: PropTypes.string,
@ -87,10 +87,10 @@ export interface IMunkiData {
}
export interface IHostMdmData {
enrollment_status: string;
enrollment_status: MdmEnrollmentStatus;
server_url: string;
id: number;
name: string;
id?: number;
name?: string;
}
export interface IMunkiIssue {
@ -203,10 +203,7 @@ export interface IHost {
users: IHostUser[];
device_users?: IDeviceUser[];
munki?: IMunkiData;
mdm: {
enrollment_status: MdmStatus;
server_url: string;
};
mdm: IHostMdmData;
policies: IHostPolicy[];
query_results?: unknown[];
geolocation?: IGeoLocation;

View File

@ -13,17 +13,17 @@ export interface IMdmAppleBm {
renew_date: string;
}
export const MDM_STATUS = {
export const MDM_ENROLLMENT_STATUS = {
"On (manual)": "manual",
"On (automatic)": "automatic",
Off: "unenrolled",
Pending: "pending",
};
export type MdmStatus = keyof typeof MDM_STATUS;
export type MdmEnrollmentStatus = keyof typeof MDM_ENROLLMENT_STATUS;
export interface IMdmStatusCardData {
status: MdmStatus;
status: MdmEnrollmentStatus;
hosts: number;
}

View File

@ -1,6 +1,12 @@
.dashboard-page {
background-color: $ui-off-white;
.data-error {
&__inner {
margin: $pad-xxxlarge 0;
}
}
&__wrapper {
background-color: $ui-off-white;
}

View File

@ -1,6 +1,6 @@
import React from "react";
import { IMdmStatusCardData, MDM_STATUS } from "interfaces/mdm";
import { IMdmStatusCardData, MDM_ENROLLMENT_STATUS } from "interfaces/mdm";
import TextCell from "components/TableContainer/DataTable/TextCell";
import TooltipWrapper from "components/TooltipWrapper";
@ -77,7 +77,8 @@ const statusTableHeaders = [
return (
<ViewAllHostsLink
queryParams={{
mdm_enrollment_status: MDM_STATUS[cellProps.row.original.status],
mdm_enrollment_status:
MDM_ENROLLMENT_STATUS[cellProps.row.original.status],
}}
className="mdm-solution-link"
platformLabelId={cellProps.row.original.selectedPlatformLabelId}

View File

@ -152,7 +152,7 @@ const allHostTableHeaders: IDataColumn[] = [
),
accessor: "display_name",
Cell: (cellProps: ICellProps) => {
if (cellProps.row.original.mdm?.enrollment_status === "Pending") {
if (cellProps.row.original.mdm.enrollment_status === "Pending") {
return (
<>
<span

View File

@ -34,7 +34,7 @@ import {
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IMunkiIssuesAggregate } from "interfaces/macadmins";
import { IMdmSolution, MDM_STATUS } from "interfaces/mdm";
import { IMdmSolution, MDM_ENROLLMENT_STATUS } from "interfaces/mdm";
import {
formatOperatingSystemDisplayName,
IOperatingSystemVersion,
@ -1236,7 +1236,9 @@ const ManageHostsPage = ({
const renderMDMEnrollmentFilterBlock = () => {
if (!mdmEnrollmentStatus) return null;
const label = `MDM status: ${invert(MDM_STATUS)[mdmEnrollmentStatus]}`;
const label = `MDM status: ${
invert(MDM_ENROLLMENT_STATUS)[mdmEnrollmentStatus]
}`;
// More narrow tooltip than other MDM tooltip
const MDM_STATUS_PILL_TOOLTIP: Record<string, JSX.Element> = {

View File

@ -271,7 +271,7 @@ const DeviceUserPage = ({
);
const renderEnrollMdmModal = () => {
return host?.mdm?.enrollment_status === "Pending" ? (
return host?.mdm.enrollment_status === "Pending" ? (
<AutoEnrollMdmModal onCancel={toggleEnrollMdmModal} />
) : (
<ManualEnrollMdmModal
@ -290,7 +290,7 @@ const DeviceUserPage = ({
) : (
<div className={`${baseClass} body-wrap`}>
{host?.platform === "darwin" &&
host?.mdm?.enrollment_status === "Off" && (
host?.mdm.enrollment_status === "Off" && (
<InfoBanner color="yellow" cta={turnOnMdmButton} pageLevel>
Mobile device management (MDM) is off. MDM allows your
organization to change settings and install software. This

View File

@ -38,7 +38,6 @@ import TabsWrapper from "components/TabsWrapper";
import MainContent from "components/MainContent";
import InfoBanner from "components/InfoBanner";
import BackLink from "components/BackLink";
import Icon from "components/Icon";
import {
normalizeEmptyValues,
@ -59,6 +58,7 @@ import PacksCard from "../cards/Packs";
import SelectQueryModal from "./modals/SelectQueryModal";
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
import OSPolicyModal from "./modals/OSPolicyModal";
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
import TransferHostModal from "../../components/TransferHostModal";
import DeleteHostModal from "../../components/DeleteHostModal";
@ -66,6 +66,7 @@ import parseOsVersion from "./modals/OSPolicyModal/helpers";
import DeleteIcon from "../../../../../assets/images/icon-action-delete-14x14@2x.png";
import QueryIcon from "../../../../../assets/images/icon-action-query-16x16@2x.png";
import TransferIcon from "../../../../../assets/images/icon-action-transfer-16x16@2x.png";
import CloseIcon from "../../../../../assets/images/icon-action-close-16x15@2x.png";
const baseClass = "host-details";
@ -131,15 +132,12 @@ const HostDetailsPage = ({
isPremiumTier && (isGlobalAdmin || isGlobalMaintainer);
const canDeleteHost = (user: IUser, host: IHost) => {
if (
return (
isGlobalAdmin ||
isGlobalMaintainer ||
permissionUtils.isTeamAdmin(user, host.team_id) ||
permissionUtils.isTeamMaintainer(user, host.team_id)
) {
return true;
}
return false;
);
};
const [showDeleteHostModal, setShowDeleteHostModal] = useState(false);
@ -147,6 +145,7 @@ const HostDetailsPage = ({
const [showQueryHostModal, setShowQueryHostModal] = useState(false);
const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false);
const [showOSPolicyModal, setShowOSPolicyModal] = useState(false);
const [showUnenrollMdmModal, setShowUnenrollMdmModal] = useState(false);
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
null
);
@ -163,6 +162,7 @@ const HostDetailsPage = ({
] = useState<IHostDiskEncryptionProps>({});
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState("");
const [hideEditMdm, setHideEditMdm] = useState<boolean>(false);
const { data: fleetQueries, error: fleetQueriesError } = useQuery<
IFleetQueriesResponse,
@ -235,7 +235,7 @@ const HostDetailsPage = ({
const refetchExtensions = () => {
deviceMapping !== null && refetchDeviceMapping();
macadmins !== null && refetchMacadmins();
mdm !== null && refetchMdm();
mdm?.enrollment_status !== null && refetchMdm();
};
const {
@ -333,6 +333,19 @@ const HostDetailsPage = ({
}
);
const canEditMdm = (() => {
const userHasPermission =
!!currentUser &&
!!host &&
(isGlobalAdmin ||
isGlobalMaintainer ||
permissionUtils.isTeamMaintainerOrTeamAdmin(currentUser, host.team_id));
const hostEnrolled = ["On (automatic)", "On (manual)"].includes(
host?.mdm.enrollment_status ?? ""
);
return userHasPermission && hostEnrolled;
})();
const featuresConfig = host?.team_id
? teams?.find((t) => t.id === host.team_id)?.features
: config?.features;
@ -410,6 +423,10 @@ const HostDetailsPage = ({
setSelectedPolicy(null);
}, [showPolicyDetailsModal, setPolicyDetailsModal, setSelectedPolicy]);
const toggleUnenrollMdmModal = useCallback(() => {
setShowUnenrollMdmModal(!showUnenrollMdmModal);
}, [showUnenrollMdmModal, setShowUnenrollMdmModal]);
const onCreateNewPolicy = () => {
const { NEW_POLICY } = PATHS;
host?.team_name
@ -521,7 +538,6 @@ const HostDetailsPage = ({
const renderActionButtons = () => {
const isOnline = host?.status === "online";
return (
<div className={`${baseClass}__action-button-container`}>
{canTransferTeam && (
@ -562,6 +578,19 @@ const HostDetailsPage = ({
You cant query <br /> an offline host.
</span>
</ReactTooltip>
{canEditMdm && !hideEditMdm && (
<Button
onClick={toggleUnenrollMdmModal}
variant="text-icon"
className={`${baseClass}__unenroll-host-from-mdm-button`}
disabled={!isOnline}
>
<>
Turn off MDM{" "}
<img src={CloseIcon} alt="Unenroll host from mdm icon" />
</>
</Button>
)}
{currentUser && host && canDeleteHost(currentUser, host) && (
<Button
onClick={() => setShowDeleteHostModal(true)}
@ -630,7 +659,7 @@ const HostDetailsPage = ({
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-links`}>
{host?.platform === "darwin" &&
host?.mdm?.enrollment_status === "Off" && (
host?.mdm.enrollment_status === "Off" && (
<InfoBanner color="yellow" pageLevel>
To change settings and install software, ask the end user to
follow the <strong>Turn on MDM</strong> instructions on their{" "}
@ -766,6 +795,15 @@ const HostDetailsPage = ({
osPolicyLabel={osPolicyLabel}
/>
)}
{showUnenrollMdmModal && !!host && (
<UnenrollMdmModal
hostId={host.id}
onClose={toggleUnenrollMdmModal}
onSuccess={() => {
setHideEditMdm(true);
}}
/>
)}
</div>
</MainContent>
);

View File

@ -0,0 +1,79 @@
import React, { useState, useContext } from "react";
import DataError from "components/DataError";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import { NotificationContext } from "context/notification";
import mdmAPI from "services/entities/mdm";
interface IUnenrollMdmModalProps {
hostId: number;
onClose: () => void;
onSuccess: () => void;
}
const baseClass = "unenroll-mdm-modal";
const UnenrollMdmModal = ({
hostId,
onClose,
onSuccess,
}: IUnenrollMdmModalProps) => {
const [requestState, setRequestState] = useState<
undefined | "unenrolling" | "error"
>(undefined);
const { renderFlash } = useContext(NotificationContext);
const submitUnenrollMdm = async () => {
setRequestState("unenrolling");
try {
await mdmAPI.unenrollHostFromMdm(hostId, 5000);
renderFlash("success", "Successfully turned off MDM.");
onSuccess();
onClose();
} catch (unenrollMdmError: unknown) {
console.log(unenrollMdmError);
setRequestState("error");
}
};
const renderModalContent = () => {
if (requestState === "error") {
return <DataError />;
}
return (
<>
<p className={`${baseClass}__description`}>
Settings configured by Fleet will be removed.
<br />
<br />
To turn on MDM again, ask the device user to follow the{" "}
<b>Turn on MDM</b> instructions on their <b>My device</b> page.
</p>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="alert"
onClick={submitUnenrollMdm}
isLoading={requestState === "unenrolling"}
>
Turn off
</Button>
<Button onClick={onClose} variant="inverse-alert">
Cancel
</Button>
</div>
</>
);
};
return (
<Modal title="Turn off MDM" onExit={onClose} className={baseClass}>
{renderModalContent()}
</Modal>
);
};
export default UnenrollMdmModal;

View File

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

View File

@ -11,7 +11,7 @@ interface IAboutProps {
aboutData: { [key: string]: any };
deviceMapping?: IDeviceUser[];
munki?: IMunkiData | null;
mdm?: IHostMdmData | null;
mdm?: IHostMdmData;
wrapFleetHelper: (helperFn: (value: any) => string, value: string) => string;
}
@ -52,7 +52,7 @@ const About = ({
};
const renderMdmData = () => {
if (!mdm) {
if (!mdm?.enrollment_status) {
return null;
}
return (

View File

@ -7,4 +7,14 @@ export default {
const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints;
return sendRequest("GET", DEVICE_USER_MDM_ENROLLMENT_PROFILE(token));
},
unenrollHostFromMdm: (hostId: number, timeout?: number) => {
const { HOST_MDM_UNENROLL } = endpoints;
return sendRequest(
"PATCH",
HOST_MDM_UNENROLL(hostId),
undefined,
undefined,
timeout
);
},
};

View File

@ -10,7 +10,8 @@ const sendRequest = async (
method: "GET" | "POST" | "PATCH" | "DELETE" | "HEAD",
path: string,
data?: unknown,
responseType: AxiosResponseType = "json"
responseType: AxiosResponseType = "json",
timeout?: number
): Promise<any> => {
const { origin } = global.window.location;
@ -23,6 +24,7 @@ const sendRequest = async (
url,
data,
responseType,
timeout,
headers: {
Authorization: `Bearer ${token}`,
},

View File

@ -34,11 +34,15 @@ 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_bm/keys`,
MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`,
// Should below 2 endpoints be consistent?
HOST_MDM: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/mdm`,
HOST_MDM_UNENROLL: (id: number) =>
`/${API_VERSION}/fleet/mdm/hosts/${id}/unenroll`,
ME: `/${API_VERSION}/fleet/me`,
OS_VERSIONS: `/${API_VERSION}/fleet/os_versions`,
OSQUERY_OPTIONS: `/${API_VERSION}/fleet/spec/osquery_options`,