From eb4acf4d1ea6ef9bd6386a5170c2129e0da036d5 Mon Sep 17 00:00:00 2001 From: Andrew Mellor Date: Fri, 7 Aug 2026 18:25:37 +0100 Subject: [PATCH] 47717 auld UI latest os version (#50571) **Related issue:** Resolves #47717 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. _Front end only_ ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added Apple OS update targeting options for no enforcement, a custom minimum version, or the latest available version. * Added configurable whole-day deadlines for macOS, iOS, and iPadOS updates. * Added platform-specific target controls, validation, and automatic new-host updates for latest-version targeting. * Displayed minimum versions, pending status, and update deadlines in host details and activity feeds. * Clarified Windows deadlines as days after release. * **Style** * Improved layout and spacing for Apple and Windows update forms. --------- Co-authored-by: Jordan Montgomery --- frontend/__mocks__/configMock.ts | 3 + frontend/interfaces/config.ts | 4 + frontend/interfaces/host.ts | 7 + .../GlobalActivityItem.tests.tsx | 40 ++ .../GlobalActivityItem/GlobalActivityItem.tsx | 13 + .../AppleOSTargetForm.tests.tsx | 616 ++++++++++++++++++ .../AppleOSTargetForm/AppleOSTargetForm.tsx | 267 ++++++-- .../components/AppleOSTargetForm/_styles.scss | 6 + .../PlatformTabs/PlatformTabs.tests.tsx | 89 +++ .../components/PlatformTabs/PlatformTabs.tsx | 9 + .../components/PlatformTabs/_styles.scss | 7 +- .../TargetSection/TargetSection.tsx | 44 ++ .../WindowsTargetForm/WindowsTargetForm.tsx | 2 +- .../components/WindowsTargetForm/_styles.scss | 5 + .../HostDetailsPage/HostDetailsPage.tsx | 23 +- .../details/cards/Vitals/Vitals.tests.tsx | 68 ++ .../hosts/details/cards/Vitals/Vitals.tsx | 56 +- .../hosts/details/cards/Vitals/_styles.scss | 7 + 18 files changed, 1153 insertions(+), 113 deletions(-) create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tests.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/_styles.scss diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 58b34a16dc..07e0a0db81 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -18,14 +18,17 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = { macos_updates: { minimum_version: "", deadline: "", + deadline_days: null, }, ios_updates: { minimum_version: "", deadline: "", + deadline_days: null, }, ipados_updates: { minimum_version: "", deadline: "", + deadline_days: null, }, apple_settings: { configuration_profiles: null, diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 8858f9297d..6ebf22c9a4 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -40,8 +40,12 @@ interface ICustomSetting { } export interface IAppleDeviceUpdates { + /** The sentinel `"latest"` enforces the newest version available, with the + * deadline derived from `deadline_days` instead of a fixed date. */ minimum_version: string; deadline: string; + /** Only set when `minimum_version` is `"latest"`; null otherwise. */ + deadline_days: number | null; update_new_hosts?: boolean; } diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 6a3aba152e..2b5cf7fc55 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -458,6 +458,13 @@ export interface IHost { conditional_access_bypassed: boolean; mdm_enrollment_hardware_attested?: boolean; dep_assigned_to_fleet: boolean; + /** The OS version this host is required to reach. Null when OS updates + * aren't configured for the host's fleet, and "Pending" while Fleet is still + * resolving the target for a "latest" requirement. */ + os_update_minimum_version?: string | null; + /** The date by which os_update_minimum_version must be installed, in + * YYYY-MM-DD. Null and "Pending" follow os_update_minimum_version. */ + os_update_deadline?: string | null; // iOS/iPadOS-only vitals collected via the DeviceInformation MDM command. // Omitted entirely (not just null) for every other platform. udid?: string; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 14ec8ae3ac..1fc4b94db4 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx @@ -219,6 +219,46 @@ describe("Activity Feed", () => { expect(screen.getByText("was added to Fleet by SSO.")).toBeInTheDocument(); }); + it("renders an edited_macos_min_version activity for a specific version", () => { + const activity = createMockActivity({ + type: ActivityType.EditedMacosMinVersion, + details: { + team_id: 1, + team_name: "Workstations", + minimum_version: "14.6.1", + deadline: "2026-09-01", + }, + }); + render(); + + expect( + screen.getByText(/updated the minimum macOS version/) + ).toBeInTheDocument(); + expect(screen.getByText("14.6.1")).toBeInTheDocument(); + expect(screen.getByText(/deadline: 2026-09-01/)).toBeInTheDocument(); + }); + + it("renders an edited_macos_min_version activity for the latest target", () => { + // "latest" is a mode rather than a version, so the sentence must not call it + // a minimum, and there's no deadline to report. + const activity = createMockActivity({ + type: ActivityType.EditedMacosMinVersion, + details: { + team_id: 1, + team_name: "Workstations", + minimum_version: "latest", + deadline: "", + }, + }); + render(); + + expect(screen.getByText(/updated macOS version to/)).toBeInTheDocument(); + expect(screen.getByText("latest")).toBeInTheDocument(); + expect(screen.getByText("Workstations")).toBeInTheDocument(); + expect(screen.queryByText(/minimum/)).not.toBeInTheDocument(); + expect(screen.queryByText(/deadline/)).not.toBeInTheDocument(); + }); + it("renders an edited_agent_options type activity for a team", () => { const activity = createMockActivity({ type: ActivityType.EditedAgentOptions, diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index dcb43c1c7b..cf73d39785 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -521,6 +521,19 @@ const TAGGED_TEMPLATES = { <>unassigned ); + // "latest" isn't a minimum -- the target floats with what Apple publishes -- + // so the sentence drops "the minimum" rather than contradicting itself. + // There's no deadline to report either: it's derived from deadline_days, + // which the activity doesn't record. + if (activity.details?.minimum_version === "latest") { + return ( + <> + {editedActivity} {applePlatform} version to latest on hosts + assigned to {teamSection}. + + ); + } + return ( <> {editedActivity} the minimum {applePlatform} version {versionSection}{" "} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx index 37caf21c34..3b858d1100 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tests.tsx @@ -41,6 +41,7 @@ describe("AppleOSTargetForm", () => { applePlatform="darwin" defaultMinOsVersion="11.0" defaultDeadline="2024-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -69,6 +70,7 @@ describe("AppleOSTargetForm", () => { applePlatform="darwin" defaultMinOsVersion="11.0" defaultDeadline="2024-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -100,6 +102,566 @@ describe("AppleOSTargetForm", () => { }); }); + // Every field is sent for every target: the config PATCH merges key by key, + // so an omitted one would leave the stored value behind. + it("sends the sentinel, no deadline and the days for 'Latest version'", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("latest"); + expect(requestBody?.mdm?.macos_updates?.deadline).toBe(""); + expect(requestBody?.mdm?.macos_updates?.deadline_days).toBe(7); + }); + }); + + it("clears every field for 'No updates enforced'", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("No updates enforced")); + await user.click(screen.getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe(""); + expect(requestBody?.mdm?.macos_updates?.deadline).toBe(""); + expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull(); + }); + }); + + it("nulls deadline_days when moving from 'Latest version' to 'Custom version'", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Custom version")); + await user.type(screen.getByLabelText(/Minimum version/i), "15.7.8"); + await user.type(screen.getByLabelText(/^Deadline$/i), "2026-09-01"); + await user.click(screen.getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("15.7.8"); + expect(requestBody?.mdm?.macos_updates?.deadline).toBe("2026-09-01"); + // The stored 7 must not survive the switch out of latest mode. + expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull(); + }); + }); + + it("sends update_new_hosts as true for 'Latest version'", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect(requestBody?.mdm?.macos_updates?.update_new_hosts).toBe(true); + }); + }); + + it("returns the checkbox to the persisted value when the target changes", async () => { + const { user } = renderWithBackend( + + ); + + const checkbox = screen.getByRole("checkbox", { + name: /update_new_hosts/i, + }); + + // Tick it without saving, so the on-screen value differs from what's stored. + await user.click(checkbox); + await waitFor(() => expect(checkbox).toBeChecked()); + + // Changing the target dismisses that unsaved input. + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("No updates enforced")); + + expect( + screen.getByRole("checkbox", { name: /update_new_hosts/i }) + ).not.toBeChecked(); + }); + + it("seeds the days field from the stored deadline_days", () => { + render( + + ); + + const daysInput = screen.getByLabelText(/Days after release/i); + expect((daysInput as HTMLInputElement).value).toBe("14"); + }); + + it("keeps the 'latest' sentinel out of the minimum version input", async () => { + const { user } = renderWithBackend( + + ); + + // "latest" is the mode, not a version the user typed, so switching to a + // custom version must start from an empty field rather than the sentinel. + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Custom version")); + + const minVersionInput = screen.getByLabelText(/Minimum version/i); + expect((minVersionInput as HTMLInputElement).value).toBe(""); + }); + + it("renders the hardware help text when the stored version is 'latest'", () => { + render( + + ); + + expect(screen.getByText(/Based on host hardware\./i)).toBeVisible(); + expect(screen.getByRole("link", { name: /Learn more/i })).toHaveAttribute( + "href", + "https://fleetdm.com/learn-more-about/apple-available-os-updates" + ); + }); + + it("shows the hardware help text only once 'Latest version' is selected", async () => { + const { user } = renderWithBackend( + + ); + + expect(screen.queryByText(/Based on host hardware\./i)).toBeNull(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Latest version")); + + expect(screen.getByText(/Based on host hardware\./i)).toBeVisible(); + }); + + it("does not render the hardware help text when no updates are enforced", () => { + render( + + ); + + expect(screen.queryByText(/Based on host hardware\./i)).toBeNull(); + }); + + it("hides the hardware help text when switching away from 'Latest version'", async () => { + const { user } = renderWithBackend( + + ); + + expect(screen.getByText(/Based on host hardware\./i)).toBeVisible(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Custom version")); + expect(screen.queryByText(/Based on host hardware\./i)).toBeNull(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("No updates enforced")); + expect(screen.queryByText(/Based on host hardware\./i)).toBeNull(); + }); + + it("shows only the days field when 'Latest version' is selected", () => { + render( + + ); + + expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Minimum version/i)).toBeNull(); + expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull(); + }); + + it("shows no version fields when no updates are enforced", () => { + render( + + ); + + expect(screen.queryByLabelText(/Minimum version/i)).toBeNull(); + expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull(); + expect(screen.queryByLabelText(/Days after release/i)).toBeNull(); + }); + + it("swaps the fields when the target changes", async () => { + const { user } = renderWithBackend( + + ); + + expect(screen.getByLabelText(/Minimum version/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Days after release/i)).toBeNull(); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Latest version")); + + expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Minimum version/i)).toBeNull(); + }); + + it("checks and disables 'update new hosts' for 'Latest version', leaving it visible", () => { + render( + + ); + + // The native input is hidden by the Checkbox component's styling, so the + // label is what proves the control is still on screen. + expect(screen.getByText(/Update new hosts to latest/i)).toBeVisible(); + + const checkbox = screen.getByLabelText(/Update new hosts to latest/i); + expect(checkbox).toBeChecked(); + expect(checkbox).toBeDisabled(); + }); + + it("leaves 'update new hosts' editable for 'Custom version'", () => { + render( + + ); + + const checkbox = screen.getByLabelText(/Update new hosts to latest/i); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toBeEnabled(); + }); + + it("shows the ADE tooltip for 'Latest version' on the checkbox", async () => { + const { user } = renderWithBackend( + + ); + + await user.hover(screen.getByText(/Update new hosts to latest/i)); + await waitFor(() => { + expect( + screen.getByText(/all hosts will be updated to latest macOS version\./i) + ).toBeInTheDocument(); + }); + }); + + it("shows the minimum version tooltip on the checkbox for 'Custom version'", async () => { + const { user } = renderWithBackend( + + ); + + await user.hover(screen.getByText(/Update new hosts to latest/i)); + await waitFor(() => { + expect( + screen.getByText(/hosts below the minimum version are updated/i) + ).toBeInTheDocument(); + }); + }); + + it("shows the days after release tooltip", async () => { + const { user } = renderWithBackend( + + ); + + await user.hover(screen.getByText(/Days after release/i)); + await waitFor(() => { + expect( + screen.getByText( + /number of days after Apple releases an update before hosts are required to install it\./i + ) + ).toBeInTheDocument(); + }); + }); + + it("requires a value in days after release before saving", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("button", { name: /Save/i })); + + expect( + await screen.findByText(/The days after release is required\./i) + ).toBeInTheDocument(); + expect(requestBody).toBeUndefined(); + }); + + it("rejects a days after release value below 1", async () => { + const { user } = renderWithBackend( + + ); + + await user.type(screen.getByLabelText(/Days after release/i), "0"); + await user.click(screen.getByRole("button", { name: /Save/i })); + + expect( + await screen.findByText(/must be a whole number of 1 or more\./i) + ).toBeInTheDocument(); + expect(requestBody).toBeUndefined(); + }); + + it("rejects a fractional days after release value", async () => { + const { user } = renderWithBackend( + + ); + + await user.type(screen.getByLabelText(/Days after release/i), "1.5"); + await user.click(screen.getByRole("button", { name: /Save/i })); + + expect( + await screen.findByText(/must be a whole number of 1 or more\./i) + ).toBeInTheDocument(); + expect(requestBody).toBeUndefined(); + }); + + it("does not validate the hidden version fields in 'Latest version'", async () => { + const { user } = renderWithBackend( + + ); + + await user.type(screen.getByLabelText(/Days after release/i), "7"); + await user.click(screen.getByRole("button", { name: /Save/i })); + + // The minimum version and deadline are empty but not on screen, so they + // must not block the save. + expect(screen.queryByText(/The minimum version is required\./i)).toBeNull(); + expect(screen.queryByText(/The deadline is required\./i)).toBeNull(); + await waitFor(() => expect(requestBody).toBeDefined()); + }); + + it("clears a validation error when the target changes", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("button", { name: /Save/i })); + expect( + await screen.findByText(/The days after release is required\./i) + ).toBeInTheDocument(); + + // Away and back: the field unmounts either way, so only returning to it + // proves the error state was cleared rather than merely hidden. + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Custom version")); + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Latest version")); + + expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument(); + expect( + screen.queryByText(/The days after release is required\./i) + ).toBeNull(); + }); + + it("requires both fields for 'Custom version' rather than clearing", async () => { + const { user } = renderWithBackend( + + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Custom version")); + await user.click(screen.getByRole("button", { name: /Save/i })); + + // Saving an empty custom form used to clear the settings, which is now what + // "No updates enforced" is for. + expect( + await screen.findByText(/The minimum version is required\./i) + ).toBeInTheDocument(); + expect(screen.getByText(/The deadline is required\./i)).toBeInTheDocument(); + expect(requestBody).toBeUndefined(); + }); + it("renders the correct form for iOS", () => { render( { applePlatform="ios" defaultMinOsVersion="11.0" defaultDeadline="2024-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -134,6 +697,7 @@ describe("AppleOSTargetForm", () => { applePlatform="ios" defaultMinOsVersion="12.0" defaultDeadline="2025-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -157,6 +721,7 @@ describe("AppleOSTargetForm", () => { applePlatform="ipados" defaultMinOsVersion="11.0" defaultDeadline="2024-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -184,6 +749,7 @@ describe("AppleOSTargetForm", () => { applePlatform="ipados" defaultMinOsVersion="13.0" defaultDeadline="2026-12-31" + defaultDeadlineDays="" defaultUpdateNewHosts refetchAppConfig={jest.fn()} refetchTeamConfig={jest.fn()} @@ -201,4 +767,54 @@ describe("AppleOSTargetForm", () => { expect(requestBody?.mdm?.ipados_updates?.deadline).toBe("2026-12-31"); }); }); + + // A rejected save must not refetch. The refetch feeds the stored config back + // in as props and resets the form, which would discard the very input the user + // needs to correct — e.g. a version Apple doesn't support. + it("keeps the entered version when the server rejects the save", async () => { + let rejected = false; + mockServer.use( + http.patch(baseUrl("/fleets/1"), () => { + rejected = true; + return HttpResponse.json( + { + message: "Validation Failed", + errors: [ + { + name: "macos_updates", + reason: "The minimum version isn't supported by Apple.", + }, + ], + }, + { status: 422 } + ); + }) + ); + + const refetchTeamConfig = jest.fn(); + const { user } = renderWithBackend( + + ); + + const minVersionInput = screen.getByLabelText(/Minimum version/i); + await user.clear(minVersionInput); + await user.type(minVersionInput, "15.1"); + await user.click(screen.getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect(rejected).toBe(true); + }); + + expect((minVersionInput as HTMLInputElement).value).toBe("15.1"); + expect(refetchTeamConfig).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx index dbacbd6bb3..20ff114497 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx @@ -10,6 +10,8 @@ import teamsAPI from "services/entities/teams"; import { ApplePlatform } from "interfaces/platform"; import InputField from "components/forms/fields/InputField"; +import DropdownWrapper from "components/forms/fields/DropdownWrapper"; +import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; import validatePresence from "components/forms/validators/validate_presence"; @@ -20,14 +22,38 @@ import { getErrorMessage } from "./helpers"; const baseClass = "apple-os-target-form"; +/** The sentinel stored in minimum_version meaning "enforce the newest version + * available", with the deadline derived from deadline_days rather than a date. */ +export const LATEST_VERSION = "latest"; + +/** Which set of fields the form collects. Not stored separately: minimum_version + * carries the mode, so the target is derived from it. */ +export type AppleOSTarget = "none" | "custom" | "latest"; + +const TARGET_OPTIONS: CustomOptionType[] = [ + { label: "No updates enforced", value: "none" }, + { label: "Custom version", value: "custom" }, + { label: "Latest version", value: "latest" }, +]; + +export const getTargetFromMinOsVersion = ( + minOsVersion: string +): AppleOSTarget => { + if (minOsVersion === LATEST_VERSION) return "latest"; + return minOsVersion ? "custom" : "none"; +}; + interface IAppleOSTargetFormData { + target: AppleOSTarget; minOsVersion: string; deadline: string; + deadlineDays: string; } interface IAppleOSTargetFormErrors { minOsVersion?: string; deadline?: string; + deadlineDays?: string; } const validateMinVersion = (value: string) => { @@ -38,17 +64,36 @@ const validateDeadline = (value: string) => { return /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/.test(value); }; +/** Whole days only, and there's no upper bound. A deadline of zero days would + * leave no time to install the update, so 1 is the lowest meaningful value. */ +const validateDeadlineDays = (value: string) => { + return /^[1-9]\d*$/.test(value); +}; + const validateForm = (formData: IAppleOSTargetFormData) => { const errors: IAppleOSTargetFormErrors = {}; - // Both fields may be cleared out and saved - if ( - !validatePresence(formData.minOsVersion) && - !validatePresence(formData.deadline) - ) { + // Nothing to validate: saving clears the version and the deadline. + if (formData.target === "none") { return errors; } + // Only the days field is shown in "latest" mode, so it's the only one that + // can be at fault — validating the hidden fields would block the save on an + // error the user can't see. + if (formData.target === "latest") { + if (!validatePresence(formData.deadlineDays)) { + errors.deadlineDays = "The days after release is required."; + } else if (!validateDeadlineDays(formData.deadlineDays)) { + errors.deadlineDays = + "Days after release must be a whole number of 1 or more."; + } + return errors; + } + + // Both fields are required for a custom version: "No updates enforced" is + // how enforcement is turned off, so an empty form here isn't a way to clear + // the settings. if (!validatePresence(formData.minOsVersion)) { errors.minOsVersion = "The minimum version is required."; } else if (!validateMinVersion(formData.minOsVersion)) { @@ -70,34 +115,55 @@ const APPLE_PLATFORMS_TO_CONFIG_FIELDS = { ipados: "ipados_updates", }; +interface IAppleOSUpdatesFields { + minimum_version: string; + deadline: string; + deadline_days: number | null; + update_new_hosts?: boolean; +} + interface IAppleUpdatesMdmConfigData { mdm: { - macos_updates?: { - minimum_version: string; - deadline: string; - }; - ipados_updates?: { - minimum_version: string; - deadline: string; - }; - ios_updates?: { - minimum_version: string; - deadline: string; - }; + macos_updates?: IAppleOSUpdatesFields; + ipados_updates?: IAppleOSUpdatesFields; + ios_updates?: IAppleOSUpdatesFields; }; } +/** Every field is sent for every target, including nulls: the config PATCH + * merges field by field, so an omitted key leaves the stored value in place. */ const createAppleOSUpdatesData = ( applePlatform: ApplePlatform, - minOsVersion: string, - deadline: string, - updateNewHosts?: boolean + formData: IAppleOSTargetFormData, + updateNewHosts: boolean ): IAppleUpdatesMdmConfigData => { + const { target, minOsVersion, deadline, deadlineDays } = formData; + + let fields: IAppleOSUpdatesFields; + switch (target) { + case "latest": + fields = { + minimum_version: LATEST_VERSION, + // A deadline can't coexist with "latest"; deadline_days replaces it. + deadline: "", + deadline_days: parseInt(deadlineDays, 10), + }; + break; + case "custom": + fields = { + minimum_version: minOsVersion, + deadline, + deadline_days: null, + }; + break; + default: + fields = { minimum_version: "", deadline: "", deadline_days: null }; + } + return { mdm: { [APPLE_PLATFORMS_TO_CONFIG_FIELDS[applePlatform]]: { - minimum_version: minOsVersion, - deadline, + ...fields, // Add update_new_hosts only for macOS right now. ...(applePlatform === "darwin" ? { update_new_hosts: updateNewHosts } @@ -112,6 +178,7 @@ interface IAppleOSTargetFormProps { applePlatform: ApplePlatform; defaultMinOsVersion: string; defaultDeadline: string; + defaultDeadlineDays: string; defaultUpdateNewHosts?: boolean; refetchAppConfig: () => void; refetchTeamConfig: () => void; @@ -122,6 +189,7 @@ const AppleOSTargetForm = ({ applePlatform, defaultMinOsVersion, defaultDeadline, + defaultDeadlineDays, defaultUpdateNewHosts, refetchAppConfig, refetchTeamConfig, @@ -130,8 +198,15 @@ const AppleOSTargetForm = ({ .gitops_mode_enabled; const [isSaving, setIsSaving] = useState(false); - const [minOsVersion, setMinOsVersion] = useState(defaultMinOsVersion); + const [target, setTarget] = useState( + getTargetFromMinOsVersion(defaultMinOsVersion) + ); + const [minOsVersion, setMinOsVersion] = useState( + // The sentinel is a mode, not a version to show in the input. + defaultMinOsVersion === LATEST_VERSION ? "" : defaultMinOsVersion + ); const [deadline, setDeadline] = useState(defaultDeadline); + const [deadlineDays, setDeadlineDays] = useState(defaultDeadlineDays); const [minOsVersionError, setMinOsVersionError] = useState< string | undefined >(); @@ -139,45 +214,70 @@ const AppleOSTargetForm = ({ defaultUpdateNewHosts || false ); const [deadlineError, setDeadlineError] = useState(); + const [deadlineDaysError, setDeadlineDaysError] = useState< + string | undefined + >(); + + // "Latest version" always updates new hosts; the other targets leave it to + // the user. Derived rather than forced into state, and shared with the payload + // so what's saved matches what the checkbox shows. + const effectiveUpdateNewHosts = target === "latest" ? true : updateNewHosts; // FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while the form is // submitting because this component is unmounted. const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const errors = validateForm({ - minOsVersion, - deadline, - }); + const formData = { target, minOsVersion, deadline, deadlineDays }; + const errors = validateForm(formData); setMinOsVersionError(errors.minOsVersion); setDeadlineError(errors.deadline); + setDeadlineDaysError(errors.deadlineDays); if (isEmpty(errors)) { setIsSaving(true); const updateData = createAppleOSUpdatesData( applePlatform, - minOsVersion, - deadline, - updateNewHosts + formData, + effectiveUpdateNewHosts ); try { currentTeamId === APP_CONTEXT_NO_TEAM_ID ? await configAPI.update(updateData) : await teamsAPI.update(updateData, currentTeamId); notify.success("Successfully updated."); + // Only refetch on success: the refetch flips isFetching, which unmounts + // this form behind TargetSection's spinner and remounts it against the + // stored config -- that's what resets it. After a rejected save nothing + // changed on the server, so resetting would just discard what the user + // needs to fix. + currentTeamId === APP_CONTEXT_NO_TEAM_ID + ? refetchAppConfig() + : refetchTeamConfig(); } catch (err) { notify.error(getErrorMessage(err as AxiosResponse), { response: err, }); } finally { - currentTeamId === APP_CONTEXT_NO_TEAM_ID - ? refetchAppConfig() - : refetchTeamConfig(); setIsSaving(false); } } }; + const handleTargetChange = (option: CustomOptionType | null) => { + if (!option) return; + setTarget(option.value as AppleOSTarget); + // "Latest version" forces the checkbox on, which isn't a choice the user + // made, so send it back to the persisted value rather than carrying the + // previous selection over. The text inputs keep whatever was typed. + setUpdateNewHosts(defaultUpdateNewHosts || false); + // The fields these belong to are about to unmount, and a stale message + // would reappear if the user came back to this target. + setMinOsVersionError(undefined); + setDeadlineError(undefined); + setDeadlineDaysError(undefined); + }; + const handleMinVersionChange = (val: string) => { setMinOsVersion(val); }; @@ -192,44 +292,87 @@ const AppleOSTargetForm = ({ return (
- - Use only versions{" "} - - + target === "latest" ? ( + <> + Based on host hardware.{" "} + + + ) : undefined } - value={minOsVersion} - error={minOsVersionError} - onChange={handleMinVersionChange} - /> - + {target === "custom" && ( + <> + + Use only versions{" "} + + + } + value={minOsVersion} + error={minOsVersionError} + onChange={handleMinVersionChange} + /> + + + )} + {target === "latest" && ( + + )} {applePlatform === "darwin" && (