fleet/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
Ian Littman 8e4e89f4e9
API + auth + UI changes for team labels (#37208)
Covers #36760, #36758.

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

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

- [ ] QA'd all new/changed functionality manually
2025-12-29 21:28:45 -06:00

595 lines
17 KiB
TypeScript

import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useDebouncedCallback } from "use-debounce";
import { IAceEditor } from "react-ace/lib/types";
import { Row } from "react-table";
import PATHS from "router/paths";
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
import idpAPI from "services/entities/idp";
import labelsAPI from "services/entities/labels";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
// TODO - move this table config near here once expanded this logic to encompass editing and
// therefore not longer needed anywhere else
import { generateTableHeaders } from "pages/labels/components/ManualLabelForm/LabelHostTargetTableConfig";
import { validateQuery } from "components/forms/validators/validate_query";
import { QueryContext } from "context/query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import { RouteComponentProps } from "react-router";
import {
LabelHostVitalsCriterion,
LabelMembershipType,
} from "interfaces/label";
import { IHost } from "interfaces/host";
import { IInputFieldParseTarget } from "interfaces/form_field";
import SidePanelPage from "components/SidePanelPage";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import SQLEditor from "components/SQLEditor";
import Icon from "components/Icon";
import TargetsInput from "components/TargetsInput";
import Radio from "components/forms/fields/Radio";
import PlatformField from "../components/PlatformField";
const availableCriteria: {
label: string;
value: LabelHostVitalsCriterion;
}[] = [
{ label: "Identity provider (IdP) group", value: "end_user_idp_group" },
{ label: "IdP department", value: "end_user_idp_department" },
];
const baseClass = "new-label-page";
export const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts";
const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER =
"Search name, hostname, or serial number";
const DEBOUNCE_DELAY = 500;
interface ITargetsQueryKey {
scope: string;
query?: string | null;
excludedHostIds?: number[];
}
export interface INewLabelFormData {
name: string;
description: string; // optional
type: LabelMembershipType;
// dynamic
labelQuery: string;
platform: string;
// host vitals
vital: LabelHostVitalsCriterion; // TODO - make use of recursive `LabelHostVitalsCriteria` type in future iterations to support logical combinations of different criteria
vitalValue: string;
// manual
targetedHosts: IHost[];
}
interface INewLabelFormErrors {
name?: string | null;
labelQuery?: string | null;
criteria?: string | null;
}
const validate = (newData: INewLabelFormData) => {
const errors: INewLabelFormErrors = {};
const { name, type, labelQuery, vitalValue } = newData;
if (!name) {
errors.name = "Label name must be present";
}
if (type === "dynamic") {
if (!labelQuery) {
errors.labelQuery = "Query text must be present";
}
} else if (type === "host_vitals") {
if (!vitalValue) {
errors.criteria = "Label criteria must be completed";
}
}
return errors;
};
const DEFAULT_DYNAMIC_QUERY = "SELECT 1 FROM os_version WHERE major >= 13;";
const NewLabelPage = ({
router,
location,
}: RouteComponentProps<never, never>) => {
// page-level state
const { selectedOsqueryTable, setSelectedOsqueryTable } = useContext(
QueryContext
);
const { isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const { isSidePanelOpen, setSidePanelOpen } = useToggleSidePanel(true);
const [showOpenSidebarButton, setShowOpenSidebarButton] = useState(false);
// page-level handlers
const onCloseSidebar = () => {
setSidePanelOpen(false);
setShowOpenSidebarButton(true);
};
const onOpenSidebar = () => {
setSidePanelOpen(true);
setShowOpenSidebarButton(false);
};
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
// form state
const [isUpdating, setIsUpdating] = useState(false);
const [formData, setFormData] = useState<INewLabelFormData>({
name: "",
description: "",
type: "dynamic", // default type
// dynamic-specific
labelQuery: DEFAULT_DYNAMIC_QUERY,
platform: "",
// host_vitals-specific
vital: "end_user_idp_group",
vitalValue: "",
// manual-specific
targetedHosts: [],
});
const {
name,
description,
type,
labelQuery,
platform,
vital,
vitalValue,
targetedHosts,
} = formData;
const [formErrors, setFormErrors] = useState<INewLabelFormErrors>({});
const [targetsSearchQuery, setTargetsSearchQuery] = useState("");
const [
debouncedTargetsSearchQuery,
setDebouncedTargetsSearchQuery,
] = useState("");
const [isDebouncingTargetsSearch, setIsDebouncingTargetsSearch] = useState(
false
);
// "manual" label target search logic
const debounceSearch = useDebouncedCallback(
(search: string) => {
setDebouncedTargetsSearchQuery(search);
setIsDebouncingTargetsSearch(false);
},
DEBOUNCE_DELAY,
{ trailing: true }
);
useEffect(() => {
setIsDebouncingTargetsSearch(true);
debounceSearch(targetsSearchQuery);
}, [debounceSearch, targetsSearchQuery]);
const {
data: targetsSearchResults,
isLoading: isLoadingTargetsSearchResults,
isError: isErrorTargetsSearchResults,
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
[
{
scope: "labels-targets-search",
query: debouncedTargetsSearchQuery,
excludedHostIds: targetedHosts.map((host) => host.id),
},
],
({ queryKey }) => {
const { query, excludedHostIds } = queryKey[0];
return targetsAPI.search({
query: query ?? "",
excluded_host_ids: excludedHostIds ?? null,
});
},
{
select: (data) => data.hosts,
enabled: type === "manual" && !!targetsSearchQuery,
}
);
const { data: scimIdPDetails } = useQuery(
["scim_details"],
() => idpAPI.getSCIMDetails(),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
}
);
const idpConfigured = !!scimIdPDetails?.last_request?.requested_at;
let hostVitalsTooltipContent: React.ReactNode;
if (!isPremiumTier) {
hostVitalsTooltipContent = (
<>
Currently, host vitals labels are based on
<br />
identity provider (IdP) groups or departments.
<br />
IdP integration available in Fleet Premium.
</>
);
} else if (!idpConfigured) {
hostVitalsTooltipContent = (
<>
Currently, host vitals labels are based on
<br />
identity provider (IdP) groups or departments.
<br />
IdP has not been configured in integration settings.
</>
);
}
useEffect(() => {
if (location.pathname.includes("dynamic")) {
router.replace(PATHS.NEW_LABEL);
}
if (location.pathname.includes("manual")) {
setFormData((prevData) => ({
...prevData,
type: "manual",
}));
router.replace(PATHS.NEW_LABEL);
}
}, [location.pathname, router]);
// form handlers
const onInputChange = ({
name: fieldName,
value,
}: IInputFieldParseTarget) => {
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
const newErrs = validate(newFormData);
// only set errors that are updates of existing errors
// new errors are only set onBlur or submit
const errsToSet: Record<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};
const onTypeChange = (value: string): void => {
const newFormData = {
...formData,
type: value as LabelMembershipType, // reconcile type differences between form data and radio component handler
};
setFormData(newFormData);
const newErrs = validate(newFormData);
const errsToSet: Record<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};
const onInputBlur = () => {
setFormErrors(validate(formData));
};
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
setIsUpdating(true);
try {
await labelsAPI.create(formData);
router.push(PATHS.MANAGE_LABELS);
renderFlash("success", "Label added successfully.");
} catch (error) {
renderFlash(
"error",
(error as { status: number }).status === 409
? "A label with this name already exists."
: "Couldn't add label. Please try again."
);
}
setIsUpdating(false);
};
const debounceValidateSQL = useDebouncedCallback((queryString: string) => {
const { error } = validateQuery(queryString);
return error || null;
}, 500);
const onQueryChange = (newQuery: string) => {
setFormData((prevData) => ({
...prevData,
labelQuery: newQuery,
}));
debounceValidateSQL(newQuery);
};
// form rendering helpers
const onLoadSQLEditor = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
enableMultiselect: false, // Disables command + click creating multiple cursors
});
// @ts-expect-error
// the string "linkClick" is not officially in the lib but we need it
editor.on("linkClick", (data) => {
const { type: type_, value } = data.token;
if (type_ === "osquery-token" && onOsqueryTableSelect) {
return onOsqueryTableSelect(value);
}
return false;
});
};
const onChangeSearchQuery = (value: string) => {
setTargetsSearchQuery(value);
};
const onHostSelect = (row: Row<IHost>) => {
setFormData((prevData) => ({
...prevData,
targetedHosts: targetedHosts.concat(row.original),
}));
setTargetsSearchQuery("");
};
const onHostRemove = (row: Row<IHost>) => {
setFormData((prevData) => ({
...prevData,
targetedHosts: targetedHosts.filter((h) => h.id !== row.original.id),
}));
};
const resultsTableConfig = generateTableHeaders();
const selectedHostsTableConfig = generateTableHeaders(onHostRemove);
const renderVariableFields = () => {
switch (type) {
case "dynamic":
return (
<>
<SQLEditor
error={formErrors.labelQuery}
name="query"
onChange={onQueryChange}
onBlur={onInputBlur}
value={labelQuery}
label="Query"
labelActionComponent={
showOpenSidebarButton ? (
<Button variant="inverse" onClick={onOpenSidebar}>
Schema
<Icon name="info" size="small" />
</Button>
) : null
}
// readOnly={isEditing} TODO when extending to handle edits
onLoad={onLoadSQLEditor}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
// helpText={isEditing ? IMMUTABLE_QUERY_HELP_TEXT : ""} TODO when extending to handle edits
wrapEnabled
/>
<PlatformField
platform={platform}
// isEditing={isEditing} TODO when extending to handle edits
// onChange={onInputChange} TODO - once this form covers edits, can use the commmon
// `onInputChange` along with updating PlatformField's Dropdown to `parseTarget`
onChange={(newPlatform) => {
setFormData((prevData) => ({
...prevData,
platform: newPlatform,
}));
}}
/>
</>
);
case "host_vitals":
return (
<div className={`${baseClass}__host_vitals-fields`}>
<label className="form-field__label" htmlFor="criterion-and-value">
Label criteria
</label>
<span id="criterion-and-value">
<Dropdown
name="vital"
onChange={onInputChange}
parseTarget
value={vital}
error={formErrors.criteria}
options={availableCriteria}
classname={`${baseClass}__criteria-dropdown`}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--criteria`}
/>
<p>is equal to</p>
<InputField
error={formErrors.criteria}
name="vitalValue"
onChange={onInputChange}
onBlur={onInputBlur}
value={vitalValue}
inputClassName={`${baseClass}__vital-value`}
placeholder={
vital === "end_user_idp_group" ? "IT admins" : "Engineering"
}
parseTarget
/>
</span>
<span className="form-field__help-text">
Currently, label criteria can be IdP group or department.
</span>
</div>
);
case "manual":
return (
<TargetsInput
label={LABEL_TARGET_HOSTS_INPUT_LABEL}
placeholder={LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER}
searchText={targetsSearchQuery}
searchResultsTableConfig={resultsTableConfig}
selectedHostsTableConifg={selectedHostsTableConfig}
isTargetsLoading={
isLoadingTargetsSearchResults || isDebouncingTargetsSearch
}
hasFetchError={isErrorTargetsSearchResults}
searchResults={targetsSearchResults ?? []}
targetedHosts={targetedHosts}
setSearchText={onChangeSearchQuery}
handleRowSelect={onHostSelect}
/>
);
default:
return null;
}
};
const renderLabelForm = () => (
<form className={`${baseClass}__label-form`} onSubmit={onSubmit}>
<InputField
error={formErrors.name}
name="name"
onChange={onInputChange}
onBlur={onInputBlur}
value={name}
inputClassName={`${baseClass}__label-name`}
label="Name"
placeholder="Label name"
parseTarget
/>
<InputField
name="description"
onChange={onInputChange}
onBlur={onInputBlur}
value={description}
inputClassName={`${baseClass}__label-description`}
label="Description"
type="textarea"
placeholder="Label description (optional)"
parseTarget
/>
<div className="form-field type-field">
<div className="form-field__label">Type</div>
<Radio
className={`${baseClass}__radio-input`}
label="Dynamic"
id="dynamic"
checked={type === "dynamic"}
value="dynamic"
name="label-type"
onChange={onTypeChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Host vitals"
id="host_vitals"
checked={type === "host_vitals"}
value="host_vitals"
name="label-type"
onChange={onTypeChange}
tooltip={hostVitalsTooltipContent}
disabled={!!hostVitalsTooltipContent}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Manual"
id="manual"
checked={type === "manual"}
value="manual"
name="label-type"
onChange={onTypeChange}
/>
</div>
{renderVariableFields()}
<div className="button-wrap">
<Button
onClick={() => {
router.goBack();
}}
variant="inverse"
disabled={isUpdating}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isUpdating}
disabled={isUpdating || !!Object.entries(formErrors).length}
>
Save
</Button>
</div>
</form>
);
return (
<SidePanelPage>
<>
<MainContent className={baseClass}>
<div className={`${baseClass}__header`}>
<h1 className="page-header">New label</h1>
<p className={`${baseClass}__page-description`}>
Create a new label for targeting and filtering hosts.
</p>
</div>
{renderLabelForm()}
</MainContent>
{type === "dynamic" && isSidePanelOpen && (
<SidePanelContent>
<QuerySidePanel
key="query-side-panel"
onOsqueryTableSelect={onOsqueryTableSelect}
selectedOsqueryTable={selectedOsqueryTable}
onClose={onCloseSidebar}
/>
</SidePanelContent>
)}
</>
</SidePanelPage>
);
};
export default NewLabelPage;