Files
Victor Lyuboslavsky a4af4d896c 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 -->
2026-08-04 16:30:02 -05:00

515 lines
15 KiB
TypeScript

/**
* This is a new component built off react-select 5.4
* meant to replace Dropdown.jsx built off react-select 1.3
*
* See storybook component for current functionality
*
* Prototyped on UserForm.tsx but added and tested the following:
* Options: text, disabled, option helptext, option tooltip
* Other: label text, dropdown help text, dropdown error
*/
import classnames from "classnames";
import React from "react";
import Select, {
components,
DropdownIndicatorProps,
GroupBase,
MenuPlacement,
OptionProps,
PropsValue,
SingleValue,
StylesConfig,
ValueContainerProps,
} from "react-select-5";
import { COLORS } from "styles/var/colors";
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";
interface CustomOptionProps
extends Omit<OptionProps<CustomOptionType, false>, "data"> {
data: CustomOptionType;
}
const baseClass = "dropdown-wrapper";
const CustomOption = (props: CustomOptionProps) => {
const { data, ...rest } = props;
const optionContent = (
<div className={`${baseClass}__option`} data-testid="dropdown-option">
{data.label}
{data.helpText && (
<span className={`${baseClass}__help-text`}>{data.helpText}</span>
)}
</div>
);
return (
<components.Option {...rest} data={data}>
{data.tooltipContent ? (
<DropdownOptionTooltipWrapper tipContent={data.tooltipContent}>
{optionContent}
</DropdownOptionTooltipWrapper>
) : (
optionContent
)}
</components.Option>
);
};
export const CustomDropdownIndicator = (
props: DropdownIndicatorProps<
CustomOptionType,
false,
GroupBase<CustomOptionType>
>
) => {
const { isFocused, selectProps } = props;
const color =
isFocused || selectProps.menuIsOpen
? "ui-fleet-black-75-over"
: "ui-fleet-black-75";
return (
<components.DropdownIndicator
{...props}
className={`${baseClass}__indicator`}
>
<Icon
name="chevron-down"
color={color}
className={`${baseClass}__icon`}
/>
</components.DropdownIndicator>
);
};
export interface CustomOptionType {
label: React.ReactNode;
value: string;
tooltipContent?: TooltipContent;
helpText?: React.ReactNode;
isDisabled?: boolean;
iconName?: IconNames;
}
type DropdownWrapperVariant = "table-filter";
export interface IDropdownWrapper {
options: CustomOptionType[];
value?: PropsValue<CustomOptionType> | string; // Future: Handle number types, cascade of type checking will be needed
onChange: (newValue: SingleValue<CustomOptionType>) => void;
name: string;
className?: string;
wrapperClassname?: string;
labelClassname?: string;
error?: string;
label?: JSX.Element | string;
helpText?: JSX.Element | string;
isSearchable?: boolean;
isDisabled?: boolean;
iconName?: IconNames;
placeholder?: string;
/** E.g. scroll to view dropdown menu in a scrollable parent container */
onMenuOpen?: () => void;
/** Table filter dropdowns have filter icon and height: 40px */
variant?: DropdownWrapperVariant;
/** This makes the menu fit all text without wrapping,
* aligning right to fit text on screen */
nowrapMenu?: boolean;
customNoOptionsMessage?: string;
/** Explicit accessible name for the combobox. When omitted, the resolved
* aria-label falls back to `placeholder`, then `name`, so existing call
* sites get at least a rough label without opting in. react-select does
* 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. */
menuPlacement?: MenuPlacement;
}
const getOptionBackgroundColor = (
state: OptionProps<CustomOptionType, false>
) => {
if (state.isFocused) return COLORS["ui-fleet-black-5"];
if (state.isSelected) return COLORS["ui-fleet-black-10"];
return "transparent";
};
/** generates the default custom styles for the dropdown component.
* NOTE: we export this from DropdownWrapper components so that other more
* customisable dropdown components can use this for consistency in styling */
export const generateCustomDropdownStyles = (
variant?: DropdownWrapperVariant,
isDisabled = false,
nowrapMenu = false,
maxMenuHeight?: number
): StylesConfig<CustomOptionType, false> => {
return {
container: (provided) => {
return {
...provided,
width: "100%",
height: "36px",
};
},
control: (provided, state) => {
return {
...provided,
display: "flex",
flexDirection: "row",
width: "100%",
minHeight: "36px", // react-select-5 defaults control minHeight to 38px
backgroundColor: COLORS["core-fleet-white"],
paddingLeft: "8px", // TODO: Update to match styleguide of (16px) when updating rest of UI (8px)
paddingRight: "8px",
cursor: "pointer",
boxShadow: "none",
borderRadius: "4px",
borderColor: state.isFocused
? COLORS["core-fleet-black"]
: COLORS["ui-fleet-black-10"],
"&:hover": {
boxShadow: "none",
borderColor: COLORS["ui-fleet-black-50"],
".dropdown-wrapper__single-value": {
color: COLORS["ui-fleet-black-75"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-75"],
},
".filter-icon path": {
fill: COLORS["ui-fleet-black-75"],
},
},
// When tabbing
// Relies on --is-focused for styling as &:focus-visible cannot be applied
"&.react-select__control--is-focused": {
borderColor: state.isFocused
? COLORS["core-fleet-black"]
: COLORS["ui-fleet-black-25"],
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-75"],
},
".filter-icon path": {
fill: COLORS["ui-fleet-black-75"],
},
},
...(state.isFocused && {
".dropdown-wrapper__placeholder": {
color: COLORS["ui-fleet-black-75"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-75"],
},
}),
...(state.isDisabled && {
".dropdown-wrapper__single-value": {
color: COLORS["ui-fleet-black-50"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-50"],
},
".filter-icon path": {
fill: COLORS["ui-fleet-black-50"],
},
}),
"&:active": {
".dropdown-wrapper__single-value": {
color: COLORS["ui-fleet-black-75"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-75"],
},
".filter-icon path": {
fill: COLORS["ui-fleet-black-75"],
},
},
...(state.menuIsOpen && {
".dropdown-wrapper__indicator svg": {
transform: "rotate(180deg)",
transition: "transform 0.25s ease",
},
}),
};
},
placeholder: (provided) => {
return {
...provided,
fontSize: "13px",
};
},
input: (provided) => {
return {
...provided,
color: COLORS["core-fleet-black"],
fontSize: "13px",
margin: 0,
padding: 0,
};
},
singleValue: (provided, state) => ({
...provided,
color: state.isDisabled
? COLORS["ui-fleet-black-50"]
: COLORS["core-fleet-black"],
fontSize: "13px",
margin: 0,
padding: 0,
}),
dropdownIndicator: (provided) => ({
...provided,
display: "flex",
padding: "2px",
svg: {
transition: "transform 0.25s ease",
},
opacity: isDisabled ? 0.5 : 1,
}),
menu: (provided) => ({
...provided,
backgroundColor: COLORS["core-fleet-white"],
boxShadow: `0 2px 6px rgba(0, 0, 0, 0.1), 0 0 0 1px ${COLORS["ui-fleet-black-10"]}`,
borderRadius: "4px",
zIndex: 6,
overflow: "hidden",
border: 0,
marginTop: "3px",
left: 0,
maxHeight: "none",
position: "absolute",
animation: "fade-in 150ms ease-out",
...(nowrapMenu && {
width: "fit-content",
left: "auto",
right: "0",
}),
}),
menuList: (provided) => ({
...provided,
padding: PADDING["pad-small"],
maxHeight: maxMenuHeight != null ? `${maxMenuHeight}px` : "none",
...(nowrapMenu && { width: "fit-content" }),
}),
valueContainer: (provided) => ({
...provided,
padding: 0,
display: "flex",
gap: PADDING["pad-small"],
flexWrap: "nowrap", // This ensures the value is on a single line and truncated
}),
option: (provided, state) => ({
...provided,
padding: "10px 8px",
fontSize: "13px",
borderRadius: "4px",
backgroundColor: getOptionBackgroundColor(state),
fontWeight: state.isSelected ? "600" : "normal",
color: COLORS["core-fleet-black"],
"&:hover": {
backgroundColor: state.isDisabled
? "transparent"
: COLORS["ui-fleet-black-5"],
cursor: state.isDisabled ? "not-allowed" : "pointer",
},
"&:active": {
backgroundColor: state.isDisabled
? "transparent"
: COLORS["ui-fleet-black-5"],
},
...(state.isDisabled && {
color: COLORS["ui-fleet-black-50"],
fontStyle: "italic",
cursor: "not-allowed",
}),
// Styles for custom option
".dropdown-wrapper__option": {
display: "flex",
flexDirection: "column",
gap: "8px",
width: "100%",
whiteSpace: nowrapMenu ? "nowrap" : "normal",
},
".dropdown-wrapper__help-text": {
fontSize: "12px",
width: "100%",
whiteSpace: nowrapMenu ? "nowrap" : "normal",
color: state.isDisabled
? COLORS["ui-fleet-black-50"]
: COLORS["ui-fleet-black-75"],
fontStyle: "italic",
fontWeight: "normal",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 999 }), // Not hidden beneath scrollable sections
noOptionsMessage: (provided) => ({
...provided,
textAlign: "left",
fontSize: "13px",
padding: "10px 8px",
}),
};
};
const DropdownWrapper = ({
options,
value,
onChange,
name,
className,
labelClassname,
wrapperClassname,
error,
label,
helpText,
isSearchable = false,
isDisabled = false,
iconName,
placeholder,
onMenuOpen,
variant,
nowrapMenu,
customNoOptionsMessage,
ariaLabel,
disabledTooltipContent,
menuPlacement = "auto",
}: IDropdownWrapper) => {
const wrapperClassNames = classnames(baseClass, className, {
[`${baseClass}__table-filter`]: variant === "table-filter",
[`${wrapperClassname}`]: !!wrapperClassname,
});
// To prevent excessively long dropdowns, this sets a max menu height for
// more than 9 options or more than than 6 options with help text
const hasHelpText = options.some((opt) => !!opt.helpText);
const enableMaxMenuHeight = hasHelpText
? options.length > 6
: options.length > 9;
const maxMenuHeight = enableMaxMenuHeight ? 305 : undefined;
const handleChange = (newValue: SingleValue<CustomOptionType>) => {
onChange(newValue);
};
// Ability to handle value of type string or CustomOptionType
const getCurrentValue = () => {
if (typeof value === "string") {
return options.find((option) => option.value === value) || null;
}
return value;
};
const ValueContainer = ({
children,
...props
}: ValueContainerProps<CustomOptionType, false>) => {
const iconToDisplay =
iconName || (variant === "table-filter" ? "filter" : null);
return (
components.ValueContainer && (
<components.ValueContainer {...props}>
{!!children && iconToDisplay && (
<Icon name={iconToDisplay} className="filter-icon" />
)}
{children}
</components.ValueContainer>
)
);
};
const renderLabel = () => {
const labelWrapperClasses = classnames(
`${baseClass}__label`,
labelClassname,
{
[`${baseClass}__label--error`]: !!error,
[`${baseClass}__label--disabled`]: isDisabled,
}
);
if (!label) {
return "";
}
return (
<label className={labelWrapperClasses} htmlFor={name}>
{error || label}
</label>
);
};
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}
label={renderLabel()}
helpText={helpText}
type="dropdown"
className={wrapperClassNames}
>
{isDisabled && disabledTooltipContent ? (
<TooltipWrapper
className={`${baseClass}__disabled-tooltip`}
tipContent={disabledTooltipContent}
position="top"
underline={false}
showArrow
>
{selectElement}
</TooltipWrapper>
) : (
selectElement
)}
</FormField>
);
};
export default DropdownWrapper;