Add default fleet for new Windows MDM enrollments (#41787) (#49922)

Demo: https://www.youtube.com/watch?v=cWxZlu9WuwA
Guide updates: https://github.com/fleetdm/fleet/pull/49603/changes

IT admins can configure the fleet that hosts enrolling through
user-driven Windows MDM enrollment (Windows Autopilot, Entra join) are
automatically assigned to, via the Windows MDM settings page, the
mdm.windows_enrollment.default_fleet config setting, or GitOps.

- New windows_enrollment_config row stores the default team; the config
API surfaces it by fleet name and hydrates reads from the row so team
renames and deletions never serve a stale name. Deleting the fleet
clears the setting.
- New edited_windows_enrollment_default_fleet activity, emitted only
when the value changes.
- The OMA-DM session persists the device-reported SMBIOS serial on
still-unlinked enrollments, and orbit enrollment reverse-links by that
serial and assigns the default fleet before orbit's one-shot
setup-experience init, so the default fleet's software, scripts, and
profiles apply during the Autopilot ESP. The DevDetail and osquery link
paths keep the same assignment as fallbacks, and the EUA-token link path
now shares the same post-link bookkeeping.
- Hosts are only assigned when new to Fleet in this enrollment cycle:
existing hosts, including ones parked in Unassigned, keep their fleet on
re-enrollment, matching macOS ABM behavior.
- GitOps defers applying the setting until teams declared in the same
run are created, and fleetctl generate-gitops exports it.
- Windows MDM settings page redesign per Figma: programmatic enrollment
toggle, User driven enrollment section with the Entra-gated Default
fleet dropdown, and a Migration section.

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41787 

# 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.

- [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.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for assigning a default Fleet Premium fleet to new
Windows MDM enrollments, including Autopilot and Entra join.
* Default-fleet settings can be configured, cleared, and managed through
Windows MDM settings and GitOps.
* Assigned fleet software, scripts, and profiles can apply during
out-of-box setup.
  * Added activity-feed visibility for default-fleet changes.
  * Improved Windows enrollment matching using hardware serial numbers.

* **Documentation**
  * Documented default-fleet assignment for Windows enrollment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-08-04 16:30:02 -05:00
committed by GitHub
parent bd601fff84
commit a4af4d896c
56 changed files with 1936 additions and 197 deletions
@@ -99,4 +99,54 @@ describe("DropdownWrapper Component", () => {
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
});
test("shows the disabled tooltip on hover when disabled with content provided", async () => {
const { container } = render(
<DropdownWrapper
options={sampleOptions}
value="option1"
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
isDisabled
disabledTooltipContent="Reason it is disabled"
/>
);
const tooltipAnchor = container.querySelector(
".dropdown-wrapper__disabled-tooltip .component__tooltip-wrapper__element"
);
expect(tooltipAnchor).toBeInTheDocument();
// react-tooltip only mounts the tip content once the anchor is hovered
await userEvent.hover(tooltipAnchor as Element);
expect(
await screen.findByText(/reason it is disabled/i)
).toBeInTheDocument();
});
// The tooltip wraps the control only when isDisabled and disabledTooltipContent are both set.
// Each case below drops one of those two operands, so neither can be removed from the condition.
test.each([
{
caseName: "enabled",
props: { disabledTooltipContent: "Reason it is disabled" },
},
{ caseName: "disabled without content", props: { isDisabled: true } },
])("does not render the disabled tooltip when $caseName", ({ props }) => {
const { container } = render(
<DropdownWrapper
options={sampleOptions}
value="option1"
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
{...props}
/>
);
expect(
container.querySelector(".dropdown-wrapper__disabled-tooltip")
).not.toBeInTheDocument();
});
});
@@ -28,6 +28,7 @@ import { PADDING } from "styles/var/padding";
import FormField from "components/forms/FormField";
import DropdownOptionTooltipWrapper from "components/forms/fields/Dropdown/DropdownOptionTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import { TooltipContent } from "interfaces/dropdownOption";
@@ -131,6 +132,8 @@ export interface IDropdownWrapper {
* not infer any of these on its own; without a value here screen readers
* announce a bare "combobox". */
ariaLabel?: string;
/** Tooltip explaining why the dropdown is disabled. Shown above the control, on hover over the control only (not the label or help text), and only while `isDisabled` is true. */
disabledTooltipContent?: React.ReactNode;
/** Defaults to "auto" so a menu near the viewport bottom flips upward
* instead of stretching the page and triggering a scrollbar-driven
* layout shift. */
@@ -381,6 +384,7 @@ const DropdownWrapper = ({
nowrapMenu,
customNoOptionsMessage,
ariaLabel,
disabledTooltipContent,
menuPlacement = "auto",
}: IDropdownWrapper) => {
const wrapperClassNames = classnames(baseClass, className, {
@@ -449,6 +453,39 @@ const DropdownWrapper = ({
);
};
const selectElement = (
<Select<CustomOptionType, false>
classNamePrefix="react-select"
isSearchable={isSearchable}
styles={generateCustomDropdownStyles(
variant,
isDisabled,
nowrapMenu,
maxMenuHeight
)}
options={options}
components={{
Option: CustomOption,
DropdownIndicator: CustomDropdownIndicator,
IndicatorSeparator: () => null,
ValueContainer,
}}
value={getCurrentValue()}
onChange={handleChange}
isDisabled={isDisabled}
noOptionsMessage={() => customNoOptionsMessage ?? "No results found"}
tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility
placeholder={placeholder}
onMenuOpen={onMenuOpen}
menuPlacement={menuPlacement}
// Resolve accessible name: explicit prop wins, otherwise fall back
// to the placeholder (usually "Select X"), otherwise the required
// `name` (often a kebab-case identifier — least readable but
// guaranteed present).
aria-label={ariaLabel ?? placeholder ?? name}
/>
);
return (
<FormField
name={name}
@@ -457,36 +494,19 @@ const DropdownWrapper = ({
type="dropdown"
className={wrapperClassNames}
>
<Select<CustomOptionType, false>
classNamePrefix="react-select"
isSearchable={isSearchable}
styles={generateCustomDropdownStyles(
variant,
isDisabled,
nowrapMenu,
maxMenuHeight
)}
options={options}
components={{
Option: CustomOption,
DropdownIndicator: CustomDropdownIndicator,
IndicatorSeparator: () => null,
ValueContainer,
}}
value={getCurrentValue()}
onChange={handleChange}
isDisabled={isDisabled}
noOptionsMessage={() => customNoOptionsMessage ?? "No results found"}
tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility
placeholder={placeholder}
onMenuOpen={onMenuOpen}
menuPlacement={menuPlacement}
// Resolve accessible name: explicit prop wins, otherwise fall back
// to the placeholder (usually "Select X"), otherwise the required
// `name` (often a kebab-case identifier — least readable but
// guaranteed present).
aria-label={ariaLabel ?? placeholder ?? name}
/>
{isDisabled && disabledTooltipContent ? (
<TooltipWrapper
className={`${baseClass}__disabled-tooltip`}
tipContent={disabledTooltipContent}
position="top"
underline={false}
showArrow
>
{selectElement}
</TooltipWrapper>
) : (
selectElement
)}
</FormField>
);
};
@@ -12,6 +12,21 @@
}
}
// Wraps the <Select> control when disabledTooltipContent is provided; the wrapper must fill the form field's width
// like the bare control does.
&__disabled-tooltip {
&.component__tooltip-wrapper {
display: block;
}
.component__tooltip-wrapper__element {
// The element is otherwise a max-content flex item, which collapses the percentage-width <Select> inside it to
// its text width.
width: 100%;
white-space: normal;
}
}
// Table dropdowns have height 40px
&__table-filter {
height: 36px;