update UI to support unenrolling android and ios and ipados devices (#32974)

resolves #31821, resolves #32120

this updates the UI to support unenrolling android and ios and ipad
devices. This includes:

**updating the host details page to include and unenroll action in the
host actions dropdown**

**Updating the unenroll modal to have dynamic content depending on the
device we are unenrolling**

**updating the global activities to have different messages for mdm
enroll and mdm unenroll actions**

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
This commit is contained in:
Gabriel Hernandez 2025-09-22 15:41:24 +01:00 committed by GitHub
parent 4a3ebc738a
commit 9593c7dec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 202 additions and 66 deletions

View file

@ -207,12 +207,12 @@ export interface IActivityDetails {
labels_exclude_any?: ILabelSoftwareTitle[];
labels_include_any?: ILabelSoftwareTitle[];
location?: string; // name of location associated with VPP token
mdm_platform?: "microsoft" | "apple";
mdm_platform?: "microsoft" | "apple" | "android" | "ios" | "ipados";
minimum_version?: string;
name?: string;
pack_id?: number;
pack_name?: string;
platform?: Platform; // software platform
platform?: Platform; // OS platform
policy_id?: number;
policy_name?: string;
profile_identifier?: string;

View file

@ -1036,23 +1036,20 @@ describe("Activity Feed", () => {
).toBeInTheDocument();
});
it("renders a 'mdm_enrolled' type for apple with host display name and personal enrollment provided", () => {
it("renders a 'mdm_enrolled' type for android or apple personal devices with actor full name provided", () => {
const activity = createMockActivity({
type: ActivityType.MdmEnrolled,
details: {
host_display_name: "Test Host",
enrollment_id: "test-enrollment-id",
mdm_platform: "apple",
platform: "android",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText((content, node) => {
return (
node?.innerHTML ===
"<b>Test User </b>An end user turned on MDM features for <b>Test Host (personal)</b>."
);
return node?.innerHTML === "<b>Test Host</b> enrolled to Fleet.";
})
).toBeInTheDocument();
});
@ -1583,4 +1580,35 @@ describe("Activity Feed", () => {
).toBeInTheDocument();
expect(screen.getByText(/HYDRANT_TEST/)).toBeInTheDocument();
});
it("renders an mdm unenroll activity with an actor name for ios, ipados, and android devices", () => {
const activity = createMockActivity({
type: ActivityType.MdmUnenrolled,
actor_full_name: "Test User",
details: {
platform: "ios",
host_display_name: "Test Host",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/Test User/)).toBeInTheDocument();
expect(screen.getByText(/told Fleet to unenroll/)).toBeInTheDocument();
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
});
it("renders an mdm unenroll activity with no actor name for ios, ipados, and android devices", () => {
const activity = createMockActivity({
type: ActivityType.MdmUnenrolled,
actor_full_name: undefined,
details: {
platform: "ios",
host_display_name: "Test Host",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
expect(screen.getByText(/is unenrolled from Fleet/)).toBeInTheDocument();
});
});

View file

@ -4,6 +4,8 @@ import React from "react";
import { ActivityType, IActivity } from "interfaces/activity";
import {
AppleDisplayPlatform,
isAndroid,
isIPadOrIPhone,
PLATFORM_DISPLAY_NAMES,
} from "interfaces/platform";
import { getInstallStatusPredicate } from "interfaces/software";
@ -272,12 +274,24 @@ const TAGGED_TEMPLATES = {
);
return <>{hostDisplayName} enrolled in Fleet.</>;
},
mdmEnrolled: (activity: IActivity) => {
if (activity.details?.mdm_platform === "microsoft") {
const { mdm_platform, platform = "", host_display_name, host_serial } =
activity.details || {};
if (mdm_platform === "microsoft") {
return (
<>
Mobile device management (MDM) was turned on for{" "}
<b>{activity.details?.host_display_name} (manual)</b>.
<b>{activity.actor_full_name} </b>Mobile device management (MDM) was
turned on for <b>{activity.details?.host_display_name} (manual)</b>.
</>
);
}
if (isAndroid(platform) || isIPadOrIPhone(platform)) {
return (
<>
<b>{host_display_name}</b> enrolled to Fleet.
</>
);
}
@ -285,24 +299,21 @@ const TAGGED_TEMPLATES = {
// note: if mdm_platform is missing, we assume this is Apple MDM for backwards
// compatibility
let enrollmentTypeText = "";
if (activity.details?.enrollment_id) {
enrollmentTypeText = "personal";
} else if (activity.details?.installed_from_dep) {
if (activity.details?.installed_from_dep) {
enrollmentTypeText = "automatic";
} else {
enrollmentTypeText = "manual";
}
const hostDisplayText =
activity.details?.host_display_name || activity.details?.host_serial;
const hostDisplayPrefixText = activity.details?.host_display_name
const hostDisplayText = host_display_name || host_serial;
const hostDisplayPrefixText = host_display_name
? ""
: "a host with serial number ";
return (
<>
An end user turned on MDM features for {hostDisplayPrefixText}
<b>{activity.actor_full_name} </b>An end user turned on MDM features for{" "}
{hostDisplayPrefixText}
<b>
{hostDisplayText} ({enrollmentTypeText})
</b>
@ -310,16 +321,39 @@ const TAGGED_TEMPLATES = {
</>
);
},
mdmUnenrolled: (activity: IActivity) => {
const { actor_full_name } = activity;
const { platform = "", host_display_name } = activity.details || {};
if (isAndroid(platform) || isIPadOrIPhone(platform)) {
return actor_full_name ? (
<>
<b>{actor_full_name}</b> told Fleet to unenroll{" "}
<b>{host_display_name}.</b>
</>
) : (
<>
<b>{host_display_name}</b> is unenrolled from Fleet.
</>
);
}
return (
<>
{activity.actor_full_name
? " told Fleet to turn off mobile device management (MDM) for"
: "Mobile device management (MDM) was turned off for"}{" "}
<b>{activity.details?.host_display_name}</b>.
{actor_full_name ? (
<>
<b>{actor_full_name}</b> told Fleet to turn off mobile device
management (MDM) for
</>
) : (
"Mobile device management (MDM) was turned off for"
)}{" "}
<b>{host_display_name}</b>.
</>
);
},
editedAppleosMinVersion: (
applePlatform: AppleDisplayPlatform,
activity: IActivity
@ -1823,7 +1857,12 @@ const GlobalActivityItem = ({
) : (
DEFAULT_ACTOR_DISPLAY
);
// MdmEnrolled and MdmUnenroll activities have more complicated logic to
// determine if we display the actor name so we will handle that in the
// template function
case ActivityType.MdmUnenrolled:
case ActivityType.MdmEnrolled:
return null;
default:
return DEFAULT_ACTOR_DISPLAY;
}

View file

@ -41,6 +41,7 @@ const HostActionsDropdown = ({
isGlobalMaintainer = false,
isMacMdmEnabledAndConfigured = false,
isWindowsMdmEnabledAndConfigured = false,
isAndroidMdmEnabledAndConfigured = false,
currentUser,
config: globalConfig,
} = useContext(AppContext);
@ -69,6 +70,7 @@ const HostActionsDropdown = ({
isConnectedToFleetMdm,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
doesStoreEncryptionKey: doesStoreEncryptionKey ?? false,
hostMdmDeviceStatus,
hostScriptsEnabled,

View file

@ -8,4 +8,8 @@
right: 0;
min-width: max-content;
}
.actions-dropdown-select__menu {
min-width: 200px;
}
}

View file

@ -66,7 +66,6 @@ const DEFAULT_OPTIONS = [
},
] as const;
// eslint-disable-next-line import/prefer-default-export
interface IHostActionConfigOptions {
hostPlatform: string;
isPremiumTier: boolean;
@ -81,6 +80,7 @@ interface IHostActionConfigOptions {
isConnectedToFleetMdm?: boolean;
isMacMdmEnabledAndConfigured: boolean;
isWindowsMdmEnabledAndConfigured: boolean;
isAndroidMdmEnabledAndConfigured: boolean;
doesStoreEncryptionKey: boolean;
hostMdmDeviceStatus: HostMdmDeviceStatusUIState;
hostScriptsEnabled: boolean | null;
@ -108,14 +108,12 @@ const canTurnOffMdm = (config: IHostActionConfigOptions) => {
isEnrolledInMdm,
isConnectedToFleetMdm,
isMacMdmEnabledAndConfigured,
hostMdmEnrollmentStatus,
isAndroidMdmEnabledAndConfigured,
} = config;
return (
!isAndroid(hostPlatform) && // TODO(android): confirm can't turn off MDM for windows, iOS, iPadOS?
isAppleDevice(hostPlatform) &&
isMacMdmEnabledAndConfigured &&
((isAndroid(hostPlatform) && isAndroidMdmEnabledAndConfigured) ||
(isAppleDevice(hostPlatform) && isMacMdmEnabledAndConfigured)) &&
isEnrolledInMdm &&
hostMdmEnrollmentStatus !== "On (personal)" && // can't turn off MDM for personally enrolled hosts
isConnectedToFleetMdm &&
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer)
);
@ -336,6 +334,19 @@ export const getDropdownOptionTooltipContent = (
return undefined;
};
/** for ios, ipad, and android we want to display different text for mdmOff.
* The functionality is the same, but the action is called unenroll on those platforms.
*/
const formatTurnOffOptionLabel = (
options: IDropdownOption[],
hostPlatform: string
) => {
const option = options.find((opt) => opt.value === "mdmOff");
if (option && (isIPadOrIPhone(hostPlatform) || isAndroid(hostPlatform))) {
option.label = "Unenroll";
}
};
const modifyOptions = (
options: IDropdownOption[],
{
@ -396,6 +407,7 @@ const modifyOptions = (
}
}
disableOptions(optionsToDisable);
formatTurnOffOptionLabel(options, hostPlatform);
return options;
};

View file

@ -1357,6 +1357,9 @@ const HostDetailsPage = ({
hostId={host.id}
hostPlatform={host.platform}
hostName={host.display_name}
isBYODEnrollment={isPersonalEnrollmentInMdm(
host.mdm.enrollment_status
)}
onClose={toggleUnenrollMdmModal}
/>
)}

View file

@ -6,13 +6,13 @@ import Modal from "components/Modal";
import { NotificationContext } from "context/notification";
import mdmAPI from "services/entities/mdm";
import { isIPadOrIPhone } from "interfaces/platform";
import CustomLink from "components/CustomLink";
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
interface IUnenrollMdmModalProps {
hostId: number;
hostPlatform: string;
hostName: string;
isBYODEnrollment?: boolean;
onClose: () => void;
}
@ -22,6 +22,7 @@ const UnenrollMdmModal = ({
hostId,
hostPlatform,
hostName,
isBYODEnrollment = false,
onClose,
}: IUnenrollMdmModalProps) => {
const [requestState, setRequestState] = useState<
@ -34,22 +35,77 @@ const UnenrollMdmModal = ({
setRequestState("unenrolling");
try {
await mdmAPI.unenrollHostFromMdm(hostId, 5000);
renderFlash(
"success",
<>
MDM will be turned off for <b>{hostName}</b> next time this host
checks in.
</>
);
const successMessage =
isIPadOrIPhone(hostPlatform) || isAndroid(hostPlatform) ? (
<>
<b>{hostName}</b> will unenrolled next time this host checks in.
</>
) : (
<>
MDM will be turned off for <b>{hostName}</b> next time this host
checks in.
</>
);
renderFlash("success", successMessage);
onClose();
} catch (unenrollMdmError: unknown) {
renderFlash(
"error",
const errorMessage =
isIPadOrIPhone(hostPlatform) || isAndroid(hostPlatform) ? (
"Couldn't unenroll. Please try again."
) : (
<>
Failed to turn off MDM for <b>{hostName}</b>. Please try again.
</>
);
renderFlash("error", errorMessage);
}
setRequestState(undefined);
};
const generateDescription = () => {
if (isIPadOrIPhone(hostPlatform)) {
return (
<>
Failed to turn off MDM for <b>{hostName}</b>.
<p>Settings configured by Fleet will be removed.</p>
{isBYODEnrollment ? (
<p>
To re-enroll, ask your end user to navigate to{" "}
<b>
Settings &gt; General &gt; VPN &amp; Device Management &gt; Sign
in to Work or School Account...
</b>{" "}
on their host and to log in with their work email.
</p>
) : (
<p>
To re-enroll, make sure that the host is still in Apple Business
Manager (ABM). The host will automatically enroll after it&apos;s
reset.
</p>
)}
</>
);
}
onClose();
if (isAndroid(hostPlatform)) {
return (
<>
<p>Company data and OS settings (work profile) will be deleted.</p>
<p>
To re-enroll, go to <b>Hosts &gt; Add hosts &gt; Android</b> and
share the link with end user.
</p>
</>
);
}
return (
<>
<p>Settings configured by Fleet will be removed.</p>
<p>
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>
</>
);
};
const renderModalContent = () => {
@ -57,30 +113,16 @@ const UnenrollMdmModal = ({
return <DataError />;
}
const turnOnMDMInstructions = isIPadOrIPhone(hostPlatform) ? (
<>
invite the end user to{" "}
<CustomLink
text="enroll a BYOD iPhone or iPad"
url="https://fleetdm.com/guides/enroll-byod-ios-ipados-hosts"
newTab
/>
</>
) : (
<>
ask the device user to follow the <b>Turn on MDM</b> instructions on
their <b>My device</b> page.
</>
);
const buttonText =
isIPadOrIPhone(hostPlatform) || isAndroid(hostPlatform)
? "Unenroll"
: "Turn off";
return (
<>
<p className={`${baseClass}__description`}>
Settings configured by Fleet will be removed.
<br />
<br />
To turn on MDM again, {turnOnMDMInstructions}
</p>
<div className={`${baseClass}__description`}>
{generateDescription()}
</div>
<div className="modal-cta-wrap">
<Button
type="submit"
@ -88,7 +130,7 @@ const UnenrollMdmModal = ({
onClick={submitUnenrollMdm}
isLoading={requestState === "unenrolling"}
>
Turn off
{buttonText}
</Button>
<Button onClick={onClose} variant="inverse-alert">
Cancel
@ -98,12 +140,18 @@ const UnenrollMdmModal = ({
);
};
const title =
isIPadOrIPhone(hostPlatform) || isAndroid(hostPlatform)
? "Unenroll"
: "Turn off MDM";
return (
<Modal
title="Turn off MDM"
title={title}
onExit={onClose}
className={baseClass}
width="medium"
isContentDisabled={requestState === "unenrolling"}
>
{renderModalContent()}
</Modal>