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 (