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:**


![image](https://github.com/fleetdm/fleet/assets/1153709/5eb8ffa2-95ce-4f43-ac38-0eac0d678614)

**exclude any on add profile modal:**


![image](https://github.com/fleetdm/fleet/assets/1153709/ec62ad7b-c8bb-45d0-a57f-f078b857dd26)

**dropdown options:**


![image](https://github.com/fleetdm/fleet/assets/1153709/015f268d-9f13-4643-8509-4543fb9a34d8)

**include all custom target modal:**


![image](https://github.com/fleetdm/fleet/assets/1153709/e18b28e0-e20d-4ae1-ad13-15ee757b4818)

**exclude any custom target modal:**


![image](https://github.com/fleetdm/fleet/assets/1153709/9009f584-d07d-4f49-9c74-59c2c36123a3)


<!-- 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:
Gabriel Hernandez
2024-06-11 13:23:30 +01:00
committed by GitHub
parent de0562a686
commit 27384c9263
14 changed files with 443 additions and 316 deletions
@@ -0,0 +1,2 @@
- add UI for uploading custom profiles with a target of hosts that include all/exclude
any selected labels
+7 -1
View File
@@ -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: {
+2
View File
@@ -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;
}
+4 -2
View File
@@ -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";
@@ -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", "Couldnt 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;
}
}
}
}
@@ -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&apos;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 wont 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
@@ -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"
@@ -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&apos;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
@@ -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;
}
}
}
@@ -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&apos;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),
};
};
@@ -0,0 +1 @@
export { default } from "./AddProfileModal";
@@ -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 =
"Couldnt add configuration profile. Please try again.";
+43 -8
View File
@@ -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("Couldnt upload. The file should include valid JSON.");
reject("Couldn't upload. The file should include valid JSON.");
}
});
});