Add UI for uploading custom profiles with target of hosts that include all/exclude any selected labels (#19587)
relates to #18848 This adds the UI that supports uploading a custom profile that applies to hosts that either: - have all selected labels - don't have any of the selected labels This includes UI updates for the Add Profile modal and the Custom Targets Modal, as well as code to support the new API. **include all on add profile modal:**  **exclude any on add profile modal:**  **dropdown options:**  **include all custom target modal:**  **exclude any custom target modal:**  <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [ ] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
- add UI for uploading custom profiles with a target of hosts that include all/exclude
|
||||
any selected labels
|
||||
@@ -29,6 +29,12 @@ export interface IMacOsMigrationSettings {
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
interface ICustomSetting {
|
||||
path: string;
|
||||
labels_include_all?: string[];
|
||||
labels_exclude_any?: string[];
|
||||
}
|
||||
|
||||
export interface IMdmConfig {
|
||||
enable_disk_encryption: boolean;
|
||||
enabled_and_configured: boolean;
|
||||
@@ -42,7 +48,7 @@ export interface IMdmConfig {
|
||||
deadline: string | null;
|
||||
};
|
||||
macos_settings: {
|
||||
custom_settings: null;
|
||||
custom_settings: null | ICustomSetting[];
|
||||
enable_disk_encryption: boolean;
|
||||
};
|
||||
macos_setup: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default PropTypes.shape({
|
||||
@@ -10,6 +11,7 @@ export interface IDropdownOption {
|
||||
disabled?: boolean;
|
||||
label: string | JSX.Element;
|
||||
value: string | number;
|
||||
helpText?: ReactNode;
|
||||
premiumOnly?: boolean;
|
||||
tooltipContent?: string | JSX.Element;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ export type ProfilePlatform = "darwin" | "windows";
|
||||
|
||||
export interface IProfileLabel {
|
||||
name: string;
|
||||
broken: boolean;
|
||||
id?: number; // id is only present when the label is not broken
|
||||
broken?: boolean;
|
||||
}
|
||||
|
||||
export interface IMdmProfile {
|
||||
@@ -83,7 +84,8 @@ export interface IMdmProfile {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
checksum: string | null; // null for windows profiles
|
||||
labels?: IProfileLabel[];
|
||||
labels_include_all?: IProfileLabel[];
|
||||
labels_exclude_any?: IProfileLabel[];
|
||||
}
|
||||
|
||||
export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed";
|
||||
|
||||
+7
-4
@@ -20,7 +20,7 @@ import Pagination from "pages/ManageControlsPage/components/Pagination";
|
||||
import UploadList from "../../../components/UploadList";
|
||||
|
||||
import AddProfileCard from "./components/ProfileUploader/components/AddProfileCard";
|
||||
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal";
|
||||
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal/AddProfileModal";
|
||||
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
|
||||
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
|
||||
import ProfileListItem from "./components/ProfileListItem";
|
||||
@@ -101,7 +101,7 @@ const CustomSettings = ({
|
||||
onMutation();
|
||||
renderFlash("success", "Successfully deleted!");
|
||||
} catch (e) {
|
||||
renderFlash("error", "Couldn’t delete. Please try again.");
|
||||
renderFlash("error", "Couldn't delete. Please try again.");
|
||||
} finally {
|
||||
selectedProfile.current = null;
|
||||
setShowDeleteProfileModal(false);
|
||||
@@ -169,6 +169,10 @@ const CustomSettings = ({
|
||||
);
|
||||
};
|
||||
|
||||
const hasLabels =
|
||||
!!profileLabelsModalData?.labels_include_all?.length ||
|
||||
!!profileLabelsModalData?.labels_exclude_any?.length;
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SectionHeader title="Custom settings" />
|
||||
@@ -189,7 +193,6 @@ const CustomSettings = ({
|
||||
)}
|
||||
{showAddProfileModal && (
|
||||
<AddProfileModal
|
||||
baseClass="add-profile"
|
||||
currentTeamId={currentTeamId}
|
||||
isPremiumTier={!!isPremiumTier}
|
||||
onUpload={onUploadProfile}
|
||||
@@ -204,7 +207,7 @@ const CustomSettings = ({
|
||||
onDelete={onDeleteProfile}
|
||||
/>
|
||||
)}
|
||||
{!!isPremiumTier && !!profileLabelsModalData?.labels?.length && (
|
||||
{isPremiumTier && hasLabels && (
|
||||
<ProfileLabelsModal
|
||||
baseClass={baseClass}
|
||||
profile={profileLabelsModalData}
|
||||
|
||||
@@ -94,136 +94,11 @@
|
||||
padding: 28.5px 0;
|
||||
}
|
||||
|
||||
&__modal-content-wrap {
|
||||
margin-top: $pad-large;
|
||||
|
||||
.add-profile__file {
|
||||
padding: $pad-medium $pad-large;
|
||||
}
|
||||
}
|
||||
|
||||
&__upload-button {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__file-chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: $pad-medium;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--button-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
&--details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__profile-graphic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: $pad-medium;
|
||||
}
|
||||
|
||||
&__target {
|
||||
margin: $pad-large 0 $pad-small 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: $pad-medium 0;
|
||||
}
|
||||
|
||||
&__no-labels {
|
||||
display: flex;
|
||||
height: 187px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__checkboxes {
|
||||
display: flex;
|
||||
max-height: 187px;
|
||||
flex-direction: column;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-spinner {
|
||||
margin: 69.5px auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
width: 100%;
|
||||
padding: $pad-small $pad-medium;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
.form-field--checkbox {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label-name {
|
||||
padding-left: $pad-large;
|
||||
}
|
||||
|
||||
.fleet-checkbox {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
width: 490px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-14
@@ -6,25 +6,42 @@ import InfoBanner from "components/InfoBanner";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
interface IModalDescriptionProps {
|
||||
baseClass: string;
|
||||
profileName: string;
|
||||
targetType: "includeAll" | "excludeAny";
|
||||
}
|
||||
|
||||
const ModalDescription = ({
|
||||
baseClass,
|
||||
profileName,
|
||||
}: {
|
||||
baseClass: string;
|
||||
profileName: string;
|
||||
}) => (
|
||||
<div className={`${baseClass}__description`}>
|
||||
<b>{profileName}</b> will only be applied to hosts that have all these
|
||||
labels:
|
||||
</div>
|
||||
);
|
||||
targetType,
|
||||
}: IModalDescriptionProps) => {
|
||||
const targetTypeText =
|
||||
targetType === "includeAll" ? (
|
||||
<>
|
||||
have <b>all</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
don't have <b>any</b>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__description`}>
|
||||
<b>{profileName}</b> profile only applies to hosts that {targetTypeText}{" "}
|
||||
of these labels:
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BrokenLabelWarning = () => (
|
||||
<InfoBanner color="yellow">
|
||||
<span>
|
||||
The configuration profile is{" "}
|
||||
<TooltipWrapper
|
||||
tipContent={`It won’t be applied to new hosts because one or more labels are deleted. To apply the profile to new hosts, please delete it and upload a new profile.`}
|
||||
tipContent={`It won't be applied to new hosts because one or more labels are deleted. To apply the profile to new hosts, please delete it and upload a new profile.`}
|
||||
underline
|
||||
>
|
||||
broken
|
||||
@@ -67,7 +84,14 @@ const ProfileLabelsModal = ({
|
||||
profile,
|
||||
setModalData,
|
||||
}: IProfileLabelsModalProps) => {
|
||||
if (!profile?.labels?.length) {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, labels_include_all, labels_exclude_any } = profile;
|
||||
const labels = labels_include_all || labels_exclude_any;
|
||||
|
||||
if (!labels?.length) {
|
||||
// caller ensures this never happens
|
||||
return null;
|
||||
}
|
||||
@@ -75,9 +99,13 @@ const ProfileLabelsModal = ({
|
||||
return (
|
||||
<Modal title="Custom target" onExit={() => setModalData(null)}>
|
||||
<div className={`${baseClass}__modal-content-wrap`}>
|
||||
{profile.labels.some((label) => label.broken) && <BrokenLabelWarning />}
|
||||
<ModalDescription baseClass={baseClass} profileName={profile.name} />
|
||||
<LabelsList baseClass={baseClass} labels={profile.labels} />
|
||||
{labels.some((label) => label.broken) && <BrokenLabelWarning />}
|
||||
<ModalDescription
|
||||
baseClass={baseClass}
|
||||
profileName={name}
|
||||
targetType={labels_include_all ? "includeAll" : "excludeAny"}
|
||||
/>
|
||||
<LabelsList baseClass={baseClass} labels={labels} />
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={() => setModalData(null)}>
|
||||
Done
|
||||
|
||||
+24
-8
@@ -83,7 +83,13 @@ const ProfileListItem = ({
|
||||
onDelete,
|
||||
setProfileLabelsModalData,
|
||||
}: IProfileListItemProps) => {
|
||||
const { created_at, labels, name, platform } = profile;
|
||||
const {
|
||||
created_at,
|
||||
labels_include_all,
|
||||
labels_exclude_any,
|
||||
name,
|
||||
platform,
|
||||
} = profile;
|
||||
const subClass = "list-item";
|
||||
|
||||
const onClickDownload = async () => {
|
||||
@@ -95,6 +101,21 @@ const ProfileListItem = ({
|
||||
FileSaver.saveAs(file);
|
||||
};
|
||||
|
||||
const labels = labels_include_all || labels_exclude_any;
|
||||
|
||||
const renderLabelInfo = () => {
|
||||
if (!isPremium || labels === undefined || labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${subClass}__labels`}>
|
||||
{labels?.some((label) => label.broken) && <Icon name="warning" />}
|
||||
<LabelCount className={subClass} count={labels.length} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classnames(subClass, baseClass)}>
|
||||
<div className={`${subClass}__main-content`}>
|
||||
@@ -111,14 +132,9 @@ const ProfileListItem = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${subClass}__actions-wrap`}>
|
||||
{isPremium && !!labels?.length && (
|
||||
<div className={`${subClass}__labels`}>
|
||||
{labels?.some((l) => l.broken) && <Icon name="warning" />}
|
||||
<LabelCount className={subClass} count={labels.length} />
|
||||
</div>
|
||||
)}
|
||||
{renderLabelInfo()}
|
||||
<div className={`${subClass}__actions`}>
|
||||
{isPremium && !!labels?.length && (
|
||||
{isPremium && labels !== undefined && labels.length && (
|
||||
<Button
|
||||
className={`${subClass}__action-button`}
|
||||
variant="text-icon"
|
||||
|
||||
+119
-82
@@ -10,6 +10,8 @@ import { ILabelSummary } from "interfaces/label";
|
||||
import labelsAPI from "services/entities/labels";
|
||||
import mdmAPI from "services/entities/mdm";
|
||||
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import Button from "components/buttons/Button";
|
||||
import Card from "components/Card";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
@@ -19,24 +21,28 @@ import Modal from "components/Modal";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import Spinner from "components/Spinner";
|
||||
|
||||
import ProfileGraphic from "./AddProfileGraphic";
|
||||
import ProfileGraphic from "../AddProfileGraphic";
|
||||
|
||||
import {
|
||||
DEFAULT_ERROR_MESSAGE,
|
||||
getErrorMessage,
|
||||
parseFile,
|
||||
} from "../../helpers";
|
||||
import {
|
||||
CUSTOM_TARGET_OPTIONS,
|
||||
CustomTargetOption,
|
||||
generateLabelKey,
|
||||
listNamesFromSelectedLabels,
|
||||
} from "../helpers";
|
||||
} from "./helpers";
|
||||
|
||||
const FileChooser = ({
|
||||
baseClass,
|
||||
isLoading,
|
||||
onFileOpen,
|
||||
}: {
|
||||
baseClass: string;
|
||||
const baseClass = "add-profile-modal";
|
||||
|
||||
interface IFileChooserProps {
|
||||
isLoading: boolean;
|
||||
onFileOpen: (files: FileList | null) => void;
|
||||
}) => (
|
||||
}
|
||||
|
||||
const FileChooser = ({ isLoading, onFileOpen }: IFileChooserProps) => (
|
||||
<div className={`${baseClass}__file-chooser`}>
|
||||
<ProfileGraphic baseClass={baseClass} showMessage />
|
||||
<Button
|
||||
@@ -62,19 +68,17 @@ const FileChooser = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddSoftwareForm.tsx and here.
|
||||
const FileDetails = ({
|
||||
baseClass,
|
||||
details: { name, platform },
|
||||
}: {
|
||||
baseClass: string;
|
||||
interface IFileDetailsProps {
|
||||
details: {
|
||||
name: string;
|
||||
platform: string;
|
||||
};
|
||||
}) => (
|
||||
}
|
||||
|
||||
// TODO: if we reuse this one more time, we should consider moving this
|
||||
// into FileUploader as a default preview. Currently we have this in
|
||||
// AddSoftwareForm.tsx and here.
|
||||
const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
|
||||
<div className={`${baseClass}__selected-file`}>
|
||||
<ProfileGraphic baseClass={baseClass} />
|
||||
<div className={`${baseClass}__selected-file--details`}>
|
||||
@@ -86,15 +90,15 @@ const FileDetails = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const TargetChooser = ({
|
||||
baseClass,
|
||||
selectedTarget,
|
||||
setSelectedTarget,
|
||||
}: {
|
||||
baseClass: string;
|
||||
interface ITargetChooserProps {
|
||||
selectedTarget: string;
|
||||
setSelectedTarget: React.Dispatch<React.SetStateAction<string>>;
|
||||
}) => {
|
||||
}
|
||||
|
||||
const TargetChooser = ({
|
||||
selectedTarget,
|
||||
setSelectedTarget,
|
||||
}: ITargetChooserProps) => {
|
||||
return (
|
||||
<div className={`form-field`}>
|
||||
<div className="form-field__label">Target</div>
|
||||
@@ -120,67 +124,95 @@ const TargetChooser = ({
|
||||
);
|
||||
};
|
||||
|
||||
const LabelChooser = ({
|
||||
baseClass,
|
||||
isError,
|
||||
isLoading,
|
||||
labels,
|
||||
selectedLabels,
|
||||
setSelectedLabels,
|
||||
}: {
|
||||
baseClass: string;
|
||||
interface ILabelChooserProps {
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
labels: ILabelSummary[];
|
||||
selectedLabels: Record<string, boolean>;
|
||||
customTargetOption: CustomTargetOption;
|
||||
setSelectedLabels: React.Dispatch<
|
||||
React.SetStateAction<Record<string, boolean>>
|
||||
>;
|
||||
}) => {
|
||||
onSelectCustomTargetOption: (val: CustomTargetOption) => void;
|
||||
}
|
||||
|
||||
const LabelChooser = ({
|
||||
isError,
|
||||
isLoading,
|
||||
labels,
|
||||
selectedLabels,
|
||||
customTargetOption,
|
||||
setSelectedLabels,
|
||||
onSelectCustomTargetOption,
|
||||
}: ILabelChooserProps) => {
|
||||
const updateSelectedLabels = useCallback(
|
||||
({ name, value }: { name: string; value: boolean }) => {
|
||||
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
|
||||
},
|
||||
[setSelectedLabels]
|
||||
);
|
||||
|
||||
const descriptionText =
|
||||
customTargetOption === "labelsIncludeAll" ? (
|
||||
<>
|
||||
Profile will only be applied to hosts that have <b>all</b> these labels:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Profile will be applied to hosts that don't have <b>any</b> of
|
||||
these labels:{" "}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderLabels = () => {
|
||||
if (isLoading) {
|
||||
return <Spinner centered={false} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
if (!labels.length) {
|
||||
return (
|
||||
<div className={`${baseClass}__no-labels`}>
|
||||
<b>No labels exist in Fleet</b>
|
||||
<span>Add labels to target specific hosts.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return labels.map((label) => {
|
||||
return (
|
||||
<div className={`${baseClass}__label`} key={label.name}>
|
||||
<Checkbox
|
||||
className={`${baseClass}__checkbox`}
|
||||
name={label.name}
|
||||
value={!!selectedLabels[label.name]}
|
||||
onChange={updateSelectedLabels}
|
||||
parseTarget
|
||||
/>
|
||||
<div className={`${baseClass}__label-name`}>{label.name}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${baseClass}__description`}>
|
||||
Profile will only be applied to hosts that have all these labels:
|
||||
</div>
|
||||
<div className={`${baseClass}__checkboxes`}>
|
||||
{isLoading && <Spinner centered={false} />}
|
||||
{!isLoading && isError && <DataError />}
|
||||
{!isLoading && !isError && !labels.length && (
|
||||
<div className={`${baseClass}__no-labels`}>
|
||||
<b>No labels exist in Fleet</b>
|
||||
<span>Add labels to target specific hosts.</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
!!labels.length &&
|
||||
labels.map((label) => {
|
||||
return (
|
||||
<div className={`${baseClass}__label`} key={label.name}>
|
||||
<Checkbox
|
||||
className={`${baseClass}__checkbox`}
|
||||
name={label.name}
|
||||
value={!!selectedLabels[label.name]}
|
||||
onChange={updateSelectedLabels}
|
||||
parseTarget
|
||||
/>
|
||||
<div className={`${baseClass}__label-name`}>{label.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
<div className={`${baseClass}__custom-label-chooser`}>
|
||||
<Dropdown
|
||||
value={customTargetOption}
|
||||
options={CUSTOM_TARGET_OPTIONS}
|
||||
searchable={false}
|
||||
onChange={onSelectCustomTargetOption}
|
||||
/>
|
||||
<div className={`${baseClass}__description`}>{descriptionText}</div>
|
||||
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAddProfileModalProps {
|
||||
baseClass: string;
|
||||
currentTeamId: number;
|
||||
isPremiumTier: boolean;
|
||||
onUpload: () => void;
|
||||
@@ -188,7 +220,6 @@ interface IAddProfileModalProps {
|
||||
}
|
||||
|
||||
const AddProfileModal = ({
|
||||
baseClass,
|
||||
currentTeamId,
|
||||
isPremiumTier,
|
||||
onUpload,
|
||||
@@ -205,18 +236,20 @@ const AddProfileModal = ({
|
||||
const [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [
|
||||
customTargetOption,
|
||||
setCustomTargetOption,
|
||||
] = useState<CustomTargetOption>("labelsIncludeAll");
|
||||
|
||||
const fileRef = useRef<File | null>(null);
|
||||
|
||||
// NOTE: labels are not automatically refetched in the current implementation
|
||||
const {
|
||||
data: labels,
|
||||
isLoading: isLoadingLabels,
|
||||
isFetching: isFetchingLabels,
|
||||
isError: isErrorLabels,
|
||||
// refetch: refetchLabels,
|
||||
} = useQuery<ILabelSummary[], Error>(
|
||||
["custom_labels"], // NOTE: consider adding selectedTarget to the queryKey to refetch labels when target changes
|
||||
["custom_labels"],
|
||||
() =>
|
||||
labelsAPI
|
||||
.summary()
|
||||
@@ -246,10 +279,15 @@ const AddProfileModal = ({
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const labelKey = generateLabelKey(
|
||||
selectedTarget,
|
||||
customTargetOption,
|
||||
selectedLabels
|
||||
);
|
||||
await mdmAPI.uploadProfile({
|
||||
file,
|
||||
teamId: currentTeamId,
|
||||
labels: listNamesFromSelectedLabels(selectedLabels),
|
||||
...labelKey,
|
||||
});
|
||||
renderFlash("success", "Successfully uploaded!");
|
||||
onUpload();
|
||||
@@ -282,6 +320,10 @@ const AddProfileModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectCustomTargetOption = (val: CustomTargetOption) => {
|
||||
setCustomTargetOption(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Add profile" onExit={onDone}>
|
||||
<>
|
||||
@@ -291,30 +333,26 @@ const AddProfileModal = ({
|
||||
<div className={`${baseClass}__modal-content-wrap`}>
|
||||
<Card color="gray" className={`${baseClass}__file`}>
|
||||
{!fileDetails ? (
|
||||
<FileChooser
|
||||
baseClass={baseClass}
|
||||
isLoading={isLoading}
|
||||
onFileOpen={onFileOpen}
|
||||
/>
|
||||
<FileChooser isLoading={isLoading} onFileOpen={onFileOpen} />
|
||||
) : (
|
||||
<FileDetails baseClass={baseClass} details={fileDetails} />
|
||||
<FileDetails details={fileDetails} />
|
||||
)}
|
||||
</Card>
|
||||
{isPremiumTier && (
|
||||
<div className={`${baseClass}__target`}>
|
||||
<TargetChooser
|
||||
baseClass={baseClass}
|
||||
selectedTarget={selectedTarget}
|
||||
setSelectedTarget={setSelectedTarget}
|
||||
/>
|
||||
{selectedTarget === "Custom" && (
|
||||
<LabelChooser
|
||||
baseClass={baseClass}
|
||||
customTargetOption={customTargetOption}
|
||||
isError={isErrorLabels}
|
||||
isLoading={isFetchingLabels}
|
||||
labels={labels || []}
|
||||
selectedLabels={selectedLabels}
|
||||
setSelectedLabels={setSelectedLabels}
|
||||
onSelectCustomTargetOption={onSelectCustomTargetOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -326,7 +364,6 @@ const AddProfileModal = ({
|
||||
onClick={onFileUpload}
|
||||
isLoading={isLoading}
|
||||
disabled={
|
||||
// TODO: consider adding tooltip to explain why button is disabled
|
||||
(selectedTarget === "Custom" &&
|
||||
!listNamesFromSelectedLabels(selectedLabels).length) ||
|
||||
!fileDetails
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
.add-profile-modal {
|
||||
&__modal-content-wrap {
|
||||
margin-top: $pad-large;
|
||||
|
||||
.add-profile__file {
|
||||
padding: $pad-medium $pad-large;
|
||||
}
|
||||
}
|
||||
|
||||
&__upload-button {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__file-chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: $pad-medium;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--button-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $pad-small;
|
||||
cursor: pointer;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__selected-file {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
&--details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&--platform {
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__profile-graphic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
&__button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: $pad-medium;
|
||||
}
|
||||
|
||||
&__target {
|
||||
margin: $pad-large 0;
|
||||
}
|
||||
|
||||
&__custom-label-chooser {
|
||||
margin-top: $pad-medium;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: $pad-medium 0;
|
||||
}
|
||||
|
||||
&__no-labels {
|
||||
display: flex;
|
||||
height: 187px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
}
|
||||
|
||||
&__checkboxes {
|
||||
display: flex;
|
||||
max-height: 187px;
|
||||
flex-direction: column;
|
||||
border-radius: $border-radius;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-spinner {
|
||||
margin: 69.5px auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
width: 100%;
|
||||
padding: $pad-small $pad-medium;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
.form-field--checkbox {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__label-name {
|
||||
padding-left: $pad-large;
|
||||
}
|
||||
|
||||
.fleet-checkbox {
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
width: 490px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import { snakeCase } from "lodash";
|
||||
|
||||
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
|
||||
{
|
||||
value: "labelsIncludeAll",
|
||||
label: "Include all ",
|
||||
helpText: (
|
||||
<>
|
||||
Profile will only be applied to hosts that have <b>all</b> of these
|
||||
labels{" "}
|
||||
</>
|
||||
),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: "labelsExcludeAny",
|
||||
label: "Exclude all",
|
||||
helpText: (
|
||||
<>
|
||||
Profile will be applied to hosts that don't have <b>any</b> of
|
||||
these labels{" "}
|
||||
</>
|
||||
),
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
||||
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
|
||||
if (isSelected) {
|
||||
acc.push(labelName);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export type CustomTargetOption = "labelsIncludeAll" | "labelsExcludeAny";
|
||||
|
||||
export const generateLabelKey = (
|
||||
target: string,
|
||||
customTargetOption: CustomTargetOption,
|
||||
selectedLabels: Record<string, boolean>
|
||||
) => {
|
||||
if (target !== "Custom") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
|
||||
};
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./AddProfileModal";
|
||||
-72
@@ -2,61 +2,6 @@ import React from "react";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
|
||||
// TODO: mobileconfig parser is a work in progress and not yet used in production
|
||||
// https://developer.apple.com/documentation/devicemanagement/configuring_multiple_devices_using_profiles#3234127
|
||||
const parseMobileconfig = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
reader.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
reader.onabort = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// parse mobile as xml
|
||||
const xmlDoc = new DOMParser().parseFromString(
|
||||
reader.result as string,
|
||||
"text/xml"
|
||||
);
|
||||
// check for any parser errors
|
||||
const parserErrors = xmlDoc.getElementsByTagName("parsererror");
|
||||
if (parserErrors.length > 0) {
|
||||
console.warn("parserErrors", parserErrors);
|
||||
throw new Error("Invalid file: parser error");
|
||||
}
|
||||
// get the top-level object, we assume it is the first `<dict>` element in the `<plist>`
|
||||
// https://developer.apple.com/documentation/devicemanagement/toplevel
|
||||
const tlo = xmlDoc.getElementsByTagName("dict")?.[0];
|
||||
if (tlo?.parentElement?.tagName !== "plist") {
|
||||
throw new Error("Invalid file: missing plist");
|
||||
}
|
||||
// get the payload display name from the top-level object, note that there may be other
|
||||
// `<dict>` elements in the `<plist>`, some of which contain `<key>PayloadDisplayName</key>`
|
||||
// elements, but we ignore those for now
|
||||
const pdnKey = Array.from(tlo.children).find(
|
||||
(child) =>
|
||||
child.tagName === "key" &&
|
||||
child.textContent === "PayloadDisplayName"
|
||||
);
|
||||
const pdnVal =
|
||||
(pdnKey?.nextElementSibling?.tagName === "string" &&
|
||||
pdnKey?.nextElementSibling?.textContent) ||
|
||||
"";
|
||||
// if the payload display name is empty, use the file name
|
||||
const result = pdnVal || file.name;
|
||||
console.log("parseMobileconfig result: ", result);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
console.error("error", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const parseFile = async (file: File): Promise<[string, string]> => {
|
||||
// get the file name and extension
|
||||
const nameParts = file.name.split(".");
|
||||
@@ -68,14 +13,6 @@ export const parseFile = async (file: File): Promise<[string, string]> => {
|
||||
return [name, "Windows"];
|
||||
}
|
||||
case "mobileconfig": {
|
||||
// // TODO: enable this once mobileconfig parser is vetted
|
||||
// try {
|
||||
// const parsedName = await parseMobileConfig(file);
|
||||
// return [parsedName, "macOS"];
|
||||
// } catch (e) {
|
||||
// console.log("error", e);
|
||||
// return [name, "macOS"];
|
||||
// }
|
||||
return [name, "macOS"];
|
||||
}
|
||||
case "json": {
|
||||
@@ -87,15 +24,6 @@ export const parseFile = async (file: File): Promise<[string, string]> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
||||
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
|
||||
if (isSelected) {
|
||||
acc.push(labelName);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
};
|
||||
|
||||
export const DEFAULT_ERROR_MESSAGE =
|
||||
"Couldn’t add configuration profile. Please try again.";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { createMockMdmProfile } from "__mocks__/mdmMock";
|
||||
import {
|
||||
DiskEncryptionStatus,
|
||||
IHostMdmProfile,
|
||||
@@ -45,7 +46,8 @@ export interface IMdmProfilesResponse {
|
||||
export interface IUploadProfileApiParams {
|
||||
file: File;
|
||||
teamId?: number;
|
||||
labels?: string[];
|
||||
labelsIncludeAll?: string[];
|
||||
labelsExcludeAny?: string[];
|
||||
}
|
||||
|
||||
export const isDDMProfile = (profile: IMdmProfile | IHostMdmProfile) => {
|
||||
@@ -62,7 +64,7 @@ export interface IAppleSetupEnrollmentProfileResponse {
|
||||
name: string;
|
||||
uploaded_at: string;
|
||||
// enrollment profile is an object with keys found here https://developer.apple.com/documentation/devicemanagement/profile.
|
||||
enrollment_profile: Record<string, any>;
|
||||
enrollment_profile: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const mdmService = {
|
||||
@@ -94,10 +96,37 @@ const mdmService = {
|
||||
...params,
|
||||
})}`;
|
||||
|
||||
// TODO: disable when API is ready;
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
profiles: [
|
||||
createMockMdmProfile({
|
||||
labels_exclude_any: [
|
||||
{
|
||||
// id: 1,
|
||||
broken: true,
|
||||
name: "Test Label",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Label 2",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
meta: { has_next_results: false, has_previous_results: false },
|
||||
});
|
||||
});
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
|
||||
uploadProfile: ({ file, teamId, labels }: IUploadProfileApiParams) => {
|
||||
uploadProfile: ({
|
||||
file,
|
||||
teamId,
|
||||
labelsIncludeAll,
|
||||
labelsExcludeAny,
|
||||
}: IUploadProfileApiParams) => {
|
||||
const { MDM_PROFILES } = endpoints;
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -107,9 +136,15 @@ const mdmService = {
|
||||
formData.append("team_id", teamId.toString());
|
||||
}
|
||||
|
||||
labels?.forEach((label) => {
|
||||
formData.append("labels", label);
|
||||
});
|
||||
if (labelsIncludeAll || labelsExcludeAny) {
|
||||
const labels = labelsIncludeAll || labelsExcludeAny;
|
||||
const labelKey = labelsIncludeAll
|
||||
? "labels_include_all"
|
||||
: "labels_exclude_any";
|
||||
labels?.forEach((label) => {
|
||||
formData.append(labelKey, label);
|
||||
});
|
||||
}
|
||||
|
||||
return sendRequest("POST", MDM_PROFILES, formData);
|
||||
},
|
||||
@@ -272,7 +307,7 @@ const mdmService = {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.addEventListener("load", () => {
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
const body: Record<string, unknown> = {
|
||||
name: file.name,
|
||||
enrollment_profile: JSON.parse(reader.result as string),
|
||||
};
|
||||
@@ -284,7 +319,7 @@ const mdmService = {
|
||||
);
|
||||
} catch {
|
||||
// catches invalid JSON
|
||||
reject("Couldn’t upload. The file should include valid JSON.");
|
||||
reject("Couldn't upload. The file should include valid JSON.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user