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 -->
515 lines
15 KiB
TypeScript
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;
|