From 1915e7122fadf15d7c7cf8affe7e20d2f0568fe5 Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Fri, 12 Dec 2025 09:46:07 -0800 Subject: [PATCH] Add "update new hosts to latest" to OS Updates form for MacOS (#37103) **Related issue:** Resolves #36088 # 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/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually Saving this value currently results in a 400 response from the server since it's not a valid key yet. We can keep this in draft until the backend is merged if we want to e2e test with it. ### Controls -> OS Settings "Target" section #### All platforms - [X] Update success banner message to "Successfully updated." image #### macOS / iOS / iPadOS - [X] Update tooltip text for "Minimum version" to `Enrolled hosts are updated to exactly this version.` Image - [X] Make "available from Apple" a link, replacing "Learn more", and link to https://fleetdm.com/learn-more-about/apple-available-os-updates image > Note - this URL is current a 404 - [X] Remove text referring to platform from "End user experience" heading, i.e. it should just say "End user experience" for all platforms where it appears, not e.g. "End user experience on macOS" #### macOS Only - [X] Add new "Update new hosts to latest" checkbox Image - [X] Reflects the `macos_updates.update_new_hosts` setting for the team or (for no team) global config (only testable via automated tests right now) - [X] Update End user experience text to "When a minimum version is enforced, end users see a native macOS notification (DDM) once per day." (see above) ### Global activity feed - [X] When "Update new hosts to latest" is enabled, activity should say `[Actor's name] enabled OS updates for all new macOS hosts on the [team name] team. macOS hosts will upgrade to the lastest version when they enroll.` - [X] When "Update new hosts to latest" is disabled, activity should say `[Actor's name] disabled updates for all new macOS hosts on the [team name] team.` (tested via automated tests) --- changes/36088-add-update-new-hosts-to-latest | 1 + frontend/interfaces/activity.ts | 4 + frontend/interfaces/config.ts | 1 + .../GlobalActivityItem.tests.tsx | 32 +++ .../GlobalActivityItem/GlobalActivityItem.tsx | 46 ++++ .../AppleOSTargetForm.tests.tsx | 204 ++++++++++++++++++ .../AppleOSTargetForm/AppleOSTargetForm.tsx | 52 +++-- .../EndUserOSRequirementPreview.tsx | 13 +- .../components/PlatformTabs/PlatformTabs.tsx | 3 + .../TargetSection/TargetSection.tsx | 28 +++ 10 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 changes/36088-add-update-new-hosts-to-latest create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx diff --git a/changes/36088-add-update-new-hosts-to-latest b/changes/36088-add-update-new-hosts-to-latest new file mode 100644 index 0000000000..7463b58269 --- /dev/null +++ b/changes/36088-add-update-new-hosts-to-latest @@ -0,0 +1 @@ +- Added ability to indicate that new MacOS hosts enrolling via ADE should be updated to the latest operating system version. diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 4cf99b53cb..a16343c5fa 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -43,6 +43,8 @@ export enum ActivityType { EditedMacosMinVersion = "edited_macos_min_version", EditedIosMinVersion = "edited_ios_min_version", EditedIpadosMinVersion = "edited_ipados_min_version", + EnabledMacosUpdateNewHosts = "enabled_macos_update_new_hosts", + DisabledMacosUpdateNewHosts = "disabled_macos_update_new_hosts", ReadHostDiskEncryptionKey = "read_host_disk_encryption_key", /** Note: BE not renamed (yet) from macOS even though activity is also used for iOS and iPadOS */ CreatedAppleOSProfile = "created_macos_profile", @@ -326,6 +328,7 @@ export const ACTIVITY_DISPLAY_NAME_MAP: Record = { disabled_macos_disk_encryption: "Turned off disk encryption", disabled_macos_setup_end_user_auth: "Turned off end user authentication (setup experience)", + disabled_macos_update_new_hosts: "Disabled OS updates for new macOS hosts", disabled_vpp: "Disabled Volume Purchasing Program (VPP)", disabled_windows_mdm: "Turned off Windows MDM", disabled_windows_mdm_migration: "Turned off Windows MDM migration", @@ -357,6 +360,7 @@ export const ACTIVITY_DISPLAY_NAME_MAP: Record = { enabled_macos_disk_encryption: "Turned on disk encryption", enabled_macos_setup_end_user_auth: "Turned on end user authentication (setup experience)", + enabled_macos_update_new_hosts: "Enabled OS updates for new macOS hosts", enabled_vpp: "Enabled Volume Purchasing Program (VPP)", enabled_windows_mdm: "Turned on Windows MDM", enabled_windows_mdm_migration: "Turned on Windows MDM migration", diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index ae790f4ac2..acd4599477 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -41,6 +41,7 @@ interface ICustomSetting { export interface IAppleDeviceUpdates { minimum_version: string; deadline: string; + update_new_hosts?: boolean; } export interface IMdmConfig { diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 1ba6e4f361..810cdaeee1 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -1734,4 +1734,36 @@ describe("Activity Feed", () => { expect(screen.getByText(/Bears/i)).toBeInTheDocument(); expect(screen.getByText(/team/i)).toBeInTheDocument(); }); + it("renders an enabledMacosUpdateNewHosts activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.EnabledMacosUpdateNewHosts, + details: { + team_name: "Lions", + team_id: 1, + }, + }); + render(); + expect( + screen.getByText(/enabled OS updates for all new/i) + ).toBeInTheDocument(); + expect(screen.getByText(/macOS/i)).toBeInTheDocument(); + expect(screen.getByText(/Lions/i)).toBeInTheDocument(); + expect(screen.getByText(/team/i)).toBeInTheDocument(); + }); + it("renders a disabledMacosUpdateNewHosts activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.DisabledMacosUpdateNewHosts, + details: { + team_name: "Lions", + team_id: 1, + }, + }); + render(); + expect( + screen.getByText(/disabled updates for all new/i) + ).toBeInTheDocument(); + expect(screen.getByText(/macOS/i)).toBeInTheDocument(); + expect(screen.getByText(/Lions/i)).toBeInTheDocument(); + expect(screen.getByText(/team/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index a0bb2f25d0..0fd54e7be9 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -435,6 +435,46 @@ const TAGGED_TEMPLATES = { ); }, + enabledAppleosUpdateNewHosts: ( + applePlatform: AppleDisplayPlatform, + activity: IActivity + ) => { + const teamSection = activity.details?.team_id ? ( + <> + the {activity.details.team_name} team + + ) : ( + <>no team + ); + + return ( + <> + enabled OS updates for all new {applePlatform} hosts on {teamSection}. + {applePlatform} hosts will upgrade to the lastest version when they + enroll. + + ); + }, + + disabledAppleosUpdateNewHosts: ( + applePlatform: AppleDisplayPlatform, + activity: IActivity + ) => { + const teamSection = activity.details?.team_id ? ( + <> + the {activity.details.team_name} team + + ) : ( + <>no team + ); + + return ( + <> + disabled updates for all new {applePlatform} hosts on {teamSection}. + + ); + }, + readHostDiskEncryptionKey: (activity: IActivity) => { return ( <> @@ -1691,6 +1731,12 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.EditedIpadosMinVersion: { return TAGGED_TEMPLATES.editedAppleosMinVersion("iPadOS", activity); } + case ActivityType.EnabledMacosUpdateNewHosts: { + return TAGGED_TEMPLATES.enabledAppleosUpdateNewHosts("macOS", activity); + } + case ActivityType.DisabledMacosUpdateNewHosts: { + return TAGGED_TEMPLATES.disabledAppleosUpdateNewHosts("macOS", activity); + } case ActivityType.ReadHostDiskEncryptionKey: { return TAGGED_TEMPLATES.readHostDiskEncryptionKey(activity); } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx new file mode 100644 index 0000000000..f684147215 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; + +import { http, HttpResponse } from "msw"; +import mockServer from "test/mock-server"; + +import AppleOSTargetForm from "./AppleOSTargetForm"; + +const baseUrl = (path: string) => { + return `/api/latest/fleet${path}`; +}; + +describe("AppleOSTargetForm", () => { + let requestBody: any; + const renderWithBackend = createCustomRenderer({ + withBackendMock: true, + }); + const updateTeamConfigHandler = http.patch( + baseUrl("/teams/1"), + async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({}); + } + ); + + beforeEach(() => { + requestBody = undefined; + mockServer.use(updateTeamConfigHandler); + }); + + afterEach(() => { + mockServer.resetHandlers(); + }); + + it("renders the correct form for MacOS", () => { + render( + + ); + + const minVersionInput = screen.getByLabelText(/Minimum version/i); + expect(minVersionInput).toBeInTheDocument(); + expect((minVersionInput as HTMLInputElement).value).toBe("11.0"); + + const deadlineInput = screen.getByLabelText(/Deadline/i); + expect(deadlineInput).toBeInTheDocument(); + expect((deadlineInput as HTMLInputElement).value).toBe("2024-12-31"); + + const updateNewHostsCheckbox = screen.getByLabelText( + /Update new hosts to latest/i + ); + expect(updateNewHostsCheckbox).toBeInTheDocument(); + expect((updateNewHostsCheckbox as HTMLInputElement).checked).toBe(true); + }); + + it("saves 'update new hosts' checkbox state correctly for macOS", async () => { + const { user } = renderWithBackend( + + ); + const saveButton = screen.getByRole("button", { name: /Save/i }); + expect(saveButton).toBeInTheDocument(); + await user.click(saveButton); + await waitFor(() => { + expect(requestBody).toBeDefined(); + expect(requestBody?.mdm?.macos_updates?.update_new_hosts).toBe(true); + expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("11.0"); + expect(requestBody?.mdm?.macos_updates?.deadline).toBe("2024-12-31"); + }); + + const updateNewHostsCheckbox = screen.getByRole("checkbox", { + name: /update_new_hosts/i, + }); + await user.click(updateNewHostsCheckbox); + await waitFor(() => { + expect(updateNewHostsCheckbox).not.toBeChecked(); + }); + await user.click(saveButton); + await waitFor(() => { + expect(requestBody).toBeDefined(); + expect(requestBody?.mdm?.macos_updates?.update_new_hosts).toBe(false); + expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("11.0"); + expect(requestBody?.mdm?.macos_updates?.deadline).toBe("2024-12-31"); + }); + }); + + it("renders the correct form for iOS", () => { + render( + + ); + + const minVersionInput = screen.getByLabelText(/Minimum version/i); + expect(minVersionInput).toBeInTheDocument(); + expect((minVersionInput as HTMLInputElement).value).toBe("11.0"); + + const deadlineInput = screen.getByLabelText(/Deadline/i); + expect(deadlineInput).toBeInTheDocument(); + expect((deadlineInput as HTMLInputElement).value).toBe("2024-12-31"); + + const updateNewHostsCheckbox = screen.queryByLabelText( + /Update new hosts to latest/i + ); + expect(updateNewHostsCheckbox).not.toBeInTheDocument(); + }); + + it("saves 'update new hosts' checkbox state correctly for iOS", async () => { + const { user } = renderWithBackend( + + ); + const saveButton = screen.getByRole("button", { name: /Save/i }); + expect(saveButton).toBeInTheDocument(); + await user.click(saveButton); + await waitFor(() => { + expect(requestBody).toBeDefined(); + expect(requestBody?.mdm?.ios_updates?.update_new_hosts).not.toBeDefined(); + expect(requestBody?.mdm?.ios_updates?.minimum_version).toBe("12.0"); + expect(requestBody?.mdm?.ios_updates?.deadline).toBe("2025-12-31"); + }); + }); + + it("renders the correct form for iPadOS", () => { + render( + + ); + + const minVersionInput = screen.getByLabelText(/Minimum version/i); + expect(minVersionInput).toBeInTheDocument(); + expect((minVersionInput as HTMLInputElement).value).toBe("11.0"); + + const deadlineInput = screen.getByLabelText(/Deadline/i); + expect(deadlineInput).toBeInTheDocument(); + expect((deadlineInput as HTMLInputElement).value).toBe("2024-12-31"); + + const updateNewHostsCheckbox = screen.queryByLabelText( + /Update new hosts to latest/i + ); + expect(updateNewHostsCheckbox).not.toBeInTheDocument(); + }); + + it("saves 'update new hosts' checkbox state correctly for iPadOS", async () => { + const { user } = renderWithBackend( + + ); + const saveButton = screen.getByRole("button", { name: /Save/i }); + expect(saveButton).toBeInTheDocument(); + await user.click(saveButton); + await waitFor(() => { + expect(requestBody).toBeDefined(); + expect( + requestBody?.mdm?.ipados_updates?.update_new_hosts + ).not.toBeDefined(); + expect(requestBody?.mdm?.ipados_updates?.minimum_version).toBe("13.0"); + expect(requestBody?.mdm?.ipados_updates?.deadline).toBe("2026-12-31"); + }); + }); +}); diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx index c936c18056..a35a257542 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx @@ -10,6 +10,7 @@ import { ApplePlatform } from "interfaces/platform"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; import validatePresence from "components/forms/validators/validate_presence"; import CustomLink from "components/CustomLink"; import { AppContext } from "context/app"; @@ -87,13 +88,18 @@ interface IAppleUpdatesMdmConfigData { const createAppleOSUpdatesData = ( applePlatform: ApplePlatform, minOsVersion: string, - deadline: string + deadline: string, + updateNewHosts?: boolean ): IAppleUpdatesMdmConfigData => { return { mdm: { [APPLE_PLATFORMS_TO_CONFIG_FIELDS[applePlatform]]: { minimum_version: minOsVersion, deadline, + // Add update_new_hosts only for macOS right now. + ...(applePlatform === "darwin" + ? { update_new_hosts: updateNewHosts } + : {}), }, }, }; @@ -104,6 +110,7 @@ interface IAppleOSTargetFormProps { applePlatform: ApplePlatform; defaultMinOsVersion: string; defaultDeadline: string; + defaultUpdateNewHosts?: boolean; refetchAppConfig: () => void; refetchTeamConfig: () => void; } @@ -113,6 +120,7 @@ const AppleOSTargetForm = ({ applePlatform, defaultMinOsVersion, defaultDeadline, + defaultUpdateNewHosts, refetchAppConfig, refetchTeamConfig, }: IAppleOSTargetFormProps) => { @@ -126,6 +134,9 @@ const AppleOSTargetForm = ({ const [minOsVersionError, setMinOsVersionError] = useState< string | undefined >(); + const [updateNewHosts, setUpdateNewHosts] = useState( + defaultUpdateNewHosts || false + ); const [deadlineError, setDeadlineError] = useState(); // FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while the form is @@ -145,13 +156,14 @@ const AppleOSTargetForm = ({ const updateData = createAppleOSUpdatesData( applePlatform, minOsVersion, - deadline + deadline, + updateNewHosts ); try { currentTeamId === APP_CONTEXT_NO_TEAM_ID ? await configAPI.update(updateData) : await teamsAPI.update(updateData, currentTeamId); - renderFlash("success", "Successfully updated minimum version!"); + renderFlash("success", "Successfully updated."); } catch { renderFlash("error", "Couldn’t update. Please try again."); } finally { @@ -172,32 +184,23 @@ const AppleOSTargetForm = ({ }; const getMinimumVersionTooltip = () => { - return ( - <> - If an already enrolled host is below the minimum version, -
the host is updated to exactly the minimum version if it's -
available from Apple. -
-
If a new or wiped host is below the minimum version and -
automatically enrolls (ADE), the host is updated to Apple's -
latest version during Setup Assistant. - - ); + return <>Enrolled hosts are updated to exactly this version.; }; return (
- Use only versions available from Apple.{" "} + Use only versions{" "} } @@ -207,6 +210,7 @@ const AppleOSTargetForm = ({ /> + {applePlatform === "darwin" && ( + + Update new hosts to latest + + )}
-

End user experience on macOS

+

End user experience

- For macOS 14 and above, end users will see native macOS - notifications (DDM). + When a minimum version is enforced, end users see a native macOS + notification (DDM) once per day.

-

Everyone else will see the Nudge window.

-

End user experience on Windows

+

End user experience

When a Windows host becomes aware of a new update, end users are able to defer restarts. Automatic restarts happen before 8am and @@ -54,13 +53,13 @@ const OSRequirementDescription = ({ case "ios": return ( <> -

End user experience on iOS

+

End user experience

); case "ipados": return ( <> -

End user experience on iPadOS

+

End user experience

); default: diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx index 7381dd9868..2c53e6df29 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx @@ -19,6 +19,7 @@ interface IPlatformTabsProps { currentTeamId: number; defaultMacOSVersion: string; defaultMacOSDeadline: string; + defaultMacOSUpdateNewHosts: boolean; defaultIOSVersion: string; defaultIOSDeadline: string; defaultIPadOSVersion: string; @@ -37,6 +38,7 @@ const PlatformTabs = ({ currentTeamId, defaultMacOSDeadline, defaultMacOSVersion, + defaultMacOSUpdateNewHosts, defaultIOSDeadline, defaultIOSVersion, defaultIPadOSDeadline, @@ -99,6 +101,7 @@ const PlatformTabs = ({ applePlatform="darwin" defaultMinOsVersion={defaultMacOSVersion} defaultDeadline={defaultMacOSDeadline} + defaultUpdateNewHosts={defaultMacOSUpdateNewHosts} key={currentTeamId} refetchAppConfig={refetchAppConfig} refetchTeamConfig={refetchTeamConfig} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx index d1d71ec3de..0cf3a10423 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx @@ -19,6 +19,27 @@ type GetDefaultFnParams = { teamConfig?: ITeamConfig; }; +const getDefaultUpdateNewHosts = ({ + osType, + currentTeamId, + appConfig, + teamConfig, +}: GetDefaultFnParams) => { + const mdmData = + currentTeamId === API_NO_TEAM_ID ? appConfig?.mdm : teamConfig?.mdm; + + switch (osType) { + case "darwin": + return !!mdmData?.macos_updates.update_new_hosts; + case "ios": + return !!mdmData?.ios_updates.update_new_hosts; + case "ipados": + return !!mdmData?.ipados_updates.update_new_hosts; + default: + return false; + } +}; + const getDefaultOSVersion = ({ osType, currentTeamId, @@ -149,6 +170,12 @@ const TargetSection = ({ appConfig, teamConfig, }); + const defaultMacOSUpdateNewHosts = getDefaultUpdateNewHosts({ + osType: "darwin", + currentTeamId, + appConfig, + teamConfig, + }); const defaultWindowsDeadlineDays = getDefaultWindowsDeadlineDays({ currentTeamId, @@ -184,6 +211,7 @@ const TargetSection = ({ defaultIPadOSDeadline={defaultIPadOSDeadline} defaultWindowsDeadlineDays={defaultWindowsDeadlineDays} defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays} + defaultMacOSUpdateNewHosts={defaultMacOSUpdateNewHosts} selectedPlatform={selectedPlatform} onSelectPlatform={onSelectPlatform} refetchAppConfig={refetchAppConfig}