Add UI for enabling manual agent install of a bootstrap package (#28550)

For #[26070](https://github.com/fleetdm/fleet/issues/26070)

This adds the UI for enabling a manual agent install for a bootstrap
package. This includes:

**The new form option for enabling manual agent install of a bootstrap
package**


![image](https://github.com/user-attachments/assets/5d271136-e41b-4c03-bbd8-09450ded82dc)

**disabling adding install software and run script options when user has
enabled manual agent install**


![image](https://github.com/user-attachments/assets/24e3ce6e-8c8f-4987-91e6-8f3fa721d67b)


![image](https://github.com/user-attachments/assets/41be4090-b97f-4ffb-ad76-001232ccd434)


**improvements to the setup experience content styling. I've created a
`SetupExperienceContentContainer` component to centralise the styles for
the content of these sub sections.**

**updates to the preview sections copy and replacing the gifs with
videos**

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [ ] Added/updated automated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
This commit is contained in:
Gabriel Hernandez 2025-04-29 15:29:21 +01:00 committed by GitHub
parent 774fb699d3
commit 789b56000f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 653 additions and 316 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
- add UI for the manual agent install of a bootstrap package

View file

@ -27,6 +27,7 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
},
macos_setup: {
bootstrap_package: "",
manual_agent_install: false,
enable_end_user_authentication: false,
macos_setup_assistant: null,
enable_release_device_manually: false,

View file

@ -8,6 +8,7 @@ import Icon from "components/Icon";
import Graphic from "components/Graphic";
import FileDetails from "components/FileDetails";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "file-uploader";
@ -48,6 +49,9 @@ interface IFileUploaderProps {
* @default "button"
*/
buttonType?: "button" | "link";
/** renders a tooltip for the button. If `gitopsCompatible` is set to `true`
* this tooltip will not be rendered if gitops mode is enabled. */
buttonTooltip?: React.ReactNode;
onFileUpload: (files: FileList | null) => void;
/** renders the current file with the edit pencil button */
canEdit?: boolean;
@ -74,6 +78,7 @@ export const FileUploader = ({
className,
buttonMessage = "Upload",
buttonType = "button",
buttonTooltip,
onFileUpload,
canEdit = false,
fileDetails,
@ -121,18 +126,22 @@ export const FileUploader = ({
));
};
const renderFileUploader = () => {
return (
<>
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
<p className={`${baseClass}__message`}>{message}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
)}
{gitopsCompatible ? (
<GitOpsModeTooltipWrapper
tipOffset={8}
renderChildren={(disableChildren) => (
const renderUploadButton = () => {
// the gitops mode tooltip wrapper takes presedence over other button
// renderings
if (gitopsCompatible) {
return (
<GitOpsModeTooltipWrapper
tipOffset={8}
renderChildren={(disableChildren) => (
<TooltipWrapper
className={`${baseClass}__manual-install-tooltip`}
tipContent={buttonTooltip}
disableTooltip={disableChildren || !buttonTooltip}
position="top"
showArrow
underline={false}
>
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
@ -146,23 +155,47 @@ export const FileUploader = ({
<span>{buttonMessage}</span>
</label>
</Button>
)}
/>
) : (
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
disabled={disabled}
customOnKeyDown={handleKeyDown}
tabIndex={0}
>
<label htmlFor="upload-file">
{buttonType === "link" && <Icon name="upload" />}
<span>{buttonMessage}</span>
</label>
</Button>
</TooltipWrapper>
)}
/>
);
}
return (
<TooltipWrapper
className={`${baseClass}__upload-button`}
position="top"
tipContent={buttonTooltip}
underline={false}
showArrow
disableTooltip={!buttonTooltip}
>
<Button
className={`${baseClass}__upload-button`}
variant={buttonVariant}
isLoading={isLoading}
disabled={disabled}
customOnKeyDown={handleKeyDown}
tabIndex={0}
>
<label htmlFor="upload-file">
{buttonType === "link" && <Icon name="upload" />}
<span>{buttonMessage}</span>
</label>
</Button>
</TooltipWrapper>
);
};
const renderFileUploader = () => {
return (
<>
<div className={`${baseClass}__graphics`}>{renderGraphics()}</div>
<p className={`${baseClass}__message`}>{message}</p>
{additionalInfo && (
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
)}
{renderUploadButton()}
<input
ref={fileInputRef}
accept={accept}

View file

@ -64,6 +64,8 @@ interface IDataTableProps {
selectedDropdownFilter?: string;
/** Set to true to persist the row selections across table data filters */
persistSelectedRows?: boolean;
/** Set to `true` to not display the footer section of the table */
hideFooter?: boolean;
onSelectSingleRow?: (value: Row) => void;
onClickRow?: (value: any) => void;
onResultsCountChange?: (value: number) => void;
@ -110,6 +112,7 @@ const DataTable = ({
searchQueryColumn,
selectedDropdownFilter,
persistSelectedRows = false,
hideFooter = false,
onSelectSingleRow,
onClickRow,
onResultsCountChange,
@ -575,34 +578,36 @@ const DataTable = ({
</tbody>
</table>
</div>
<div className={`${baseClass}__footer`}>
{renderTableHelpText && !!rows?.length && (
<div className={`${baseClass}__table-help-text`}>
{renderTableHelpText()}
</div>
)}
{isClientSidePagination ? (
<Pagination
disablePrev={!canPreviousPage}
disableNext={!canNextPage}
onPrevPage={() => {
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
onClientSidePaginationChange &&
onClientSidePaginationChange(pageIndex - 1);
previousPage();
}}
onNextPage={() => {
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
onClientSidePaginationChange &&
onClientSidePaginationChange(pageIndex + 1);
nextPage();
}}
hidePagination={!canPreviousPage && !canNextPage}
/>
) : (
renderPagination && renderPagination()
)}
</div>
{!hideFooter && (
<div className={`${baseClass}__footer`}>
{renderTableHelpText && !!rows?.length && (
<div className={`${baseClass}__table-help-text`}>
{renderTableHelpText()}
</div>
)}
{isClientSidePagination ? (
<Pagination
disablePrev={!canPreviousPage}
disableNext={!canNextPage}
onPrevPage={() => {
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
onClientSidePaginationChange &&
onClientSidePaginationChange(pageIndex - 1);
previousPage();
}}
onNextPage={() => {
toggleAllRowsSelected(false); // Resets row selection on pagination (client-side)
onClientSidePaginationChange &&
onClientSidePaginationChange(pageIndex + 1);
nextPage();
}}
hidePagination={!canPreviousPage && !canNextPage}
/>
) : (
renderPagination && renderPagination()
)}
</div>
)}
</div>
);
};

View file

@ -114,6 +114,8 @@ interface ITableContainerProps<T = any> {
disableTableHeader?: boolean;
/** Set to true to persist the row selections across table data filters */
persistSelectedRows?: boolean;
/** Set to `true` to not display the footer section of the table */
hideFooter?: boolean;
/** handler called when the `clear selection` button is called */
onClearSelection?: () => void;
}
@ -161,6 +163,7 @@ const TableContainer = <T,>({
pageSize = DEFAULT_PAGE_SIZE,
selectedDropdownFilter,
searchQueryColumn,
hideFooter,
onQueryChange,
customControl,
customFiltersButton,
@ -558,6 +561,7 @@ const TableContainer = <T,>({
setExportRows={setExportRows}
onClearSelection={onClearSelection}
persistSelectedRows={persistSelectedRows}
hideFooter={hideFooter}
/>
</div>
</>

View file

@ -74,6 +74,7 @@ export interface IMdmConfig {
enable_end_user_authentication: boolean;
macos_setup_assistant: string | null;
enable_release_device_manually: boolean | null;
manual_agent_install: boolean | null;
};
macos_migration: IMacOsMigrationSettings;
windows_updates: {

View file

@ -61,6 +61,7 @@ export interface ITeam extends ITeamSummary {
enable_end_user_authentication: boolean;
macos_setup_assistant: string | null;
enable_release_device_manually: boolean | null;
manual_agent_install: boolean | null;
};
windows_updates: {
deadline_days: number | null;

View file

@ -6,7 +6,7 @@ import EndUserAuthentication from "./cards/EndUserAuthentication/EndUserAuthenti
import BootstrapPackage from "./cards/BootstrapPackage";
import SetupAssistant from "./cards/SetupAssistant";
import InstallSoftware from "./cards/InstallSoftware";
import SetupExperienceScript from "./cards/SetupExperienceScript";
import RunScript from "./cards/RunScript";
interface ISetupExperienceCardProps {
currentTeamId?: number;
@ -23,28 +23,28 @@ const SETUP_EXPERIENCE_NAV_ITEMS: ISideNavItem<
Card: EndUserAuthentication,
},
{
title: "2. Setup assistant",
urlSection: "setup-assistant",
path: PATHS.CONTROLS_SETUP_ASSITANT,
Card: SetupAssistant,
},
{
title: "3. Bootstrap package",
title: "2. Bootstrap package",
urlSection: "bootstrap-package",
path: PATHS.CONTROLS_BOOTSTRAP_PACKAGE,
Card: BootstrapPackage,
},
{
title: "4. Install software",
title: "3. Install software",
urlSection: "install-software",
path: PATHS.CONTROLS_INSTALL_SOFTWARE,
Card: InstallSoftware,
},
{
title: "5. Run script",
title: "4. Run script",
urlSection: "run-script",
path: PATHS.CONTROLS_RUN_SCRIPT,
Card: SetupExperienceScript,
Card: RunScript,
},
{
title: "5. Setup assistant",
urlSection: "setup-assistant",
path: PATHS.CONTROLS_SETUP_ASSITANT,
Card: SetupAssistant,
},
];

View file

@ -4,8 +4,13 @@ import { AxiosResponse } from "axios";
import { IBootstrapPackageMetadata } from "interfaces/mdm";
import { IApiError } from "interfaces/errors";
import { IConfig } from "interfaces/config";
import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team";
import mdmAPI from "services/entities/mdm";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { NotificationContext } from "context/notification";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Spinner from "components/Spinner";
import SectionHeader from "components/SectionHeader";
@ -13,17 +18,72 @@ import SectionHeader from "components/SectionHeader";
import BootstrapPackagePreview from "./components/BootstrapPackagePreview";
import PackageUploader from "./components/BootstrapPackageUploader";
import UploadedPackageView from "./components/UploadedPackageView";
import DeletePackageModal from "./components/DeletePackageModal";
import DeleteBootstrapPackageModal from "./components/DeleteBootstrapPackageModal";
import SetupExperienceContentContainer from "../../components/SetupExperienceContentContainer";
import BootstrapAdvancedOptions from "./components/BootstrapAdvancedOptions";
const baseClass = "bootstrap-package";
export const getManualAgentInstallSetting = (
currentTeamId: number,
globalConfig?: IConfig,
teamConfig?: ITeamConfig
) => {
if (currentTeamId === API_NO_TEAM_ID) {
return globalConfig?.mdm.macos_setup.manual_agent_install || false;
}
return teamConfig?.mdm?.macos_setup.manual_agent_install || false;
};
interface IBootstrapPackageProps {
currentTeamId: number;
}
const BootstrapPackage = ({ currentTeamId }: IBootstrapPackageProps) => {
const { renderFlash } = useContext(NotificationContext);
const [showDeletePackageModal, setShowDeletePackageModal] = useState(false);
const [
selectedManualAgentInstall,
setSelectedManualAgentInstall,
] = useState<boolean>(false);
const [
showDeleteBootstrapPackageModal,
setShowDeleteBootstrapPackageModal,
] = useState(false);
const {
isLoading: isLoadingGlobalConfig,
refetch: refetchGlobalConfig,
} = useQuery<IConfig, Error>(
["config", currentTeamId],
() => configAPI.loadAll(),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId === API_NO_TEAM_ID,
onSuccess: (data) => {
setSelectedManualAgentInstall(
getManualAgentInstallSetting(currentTeamId, data)
);
},
}
);
const {
isLoading: isLoadingTeamConfig,
refetch: refetchTeamConfig,
} = useQuery<ILoadTeamResponse, Error, ITeamConfig>(
["team", currentTeamId],
() => teamsAPI.load(currentTeamId),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId !== API_NO_TEAM_ID,
select: (res) => res.team,
onSuccess: (data) => {
setSelectedManualAgentInstall(
getManualAgentInstallSetting(currentTeamId, undefined, data)
);
},
}
);
const {
data: bootstrapMetadata,
@ -51,12 +111,21 @@ const BootstrapPackage = ({ currentTeamId }: IBootstrapPackageProps) => {
const onDelete = async () => {
try {
await mdmAPI.deleteBootstrapPackage(currentTeamId);
await mdmAPI.updateSetupExperienceSettings({
team_id: currentTeamId,
manual_agent_install: false,
});
renderFlash("success", "Successfully deleted!");
} catch {
renderFlash("error", "Couldnt delete. Please try again.");
renderFlash("error", "Couldn't delete. Please try again.");
} finally {
setShowDeletePackageModal(false);
setShowDeleteBootstrapPackageModal(false);
refretchBootstrapMetadata();
if (currentTeamId !== API_NO_TEAM_ID) {
refetchTeamConfig();
} else {
refetchGlobalConfig();
}
}
};
@ -65,44 +134,52 @@ const BootstrapPackage = ({ currentTeamId }: IBootstrapPackageProps) => {
const noPackageUploaded =
(error && error.status === 404) || !bootstrapMetadata;
const renderBootstrapView = () => {
const bootstrapPackageView = noPackageUploaded ? (
<PackageUploader currentTeamId={currentTeamId} onUpload={onUpload} />
) : (
<UploadedPackageView
bootstrapPackage={bootstrapMetadata}
currentTeamId={currentTeamId}
onDelete={() => setShowDeleteBootstrapPackageModal(true)}
/>
);
return (
<SetupExperienceContentContainer className={`${baseClass}__content`}>
<div className={`${baseClass}__uploader-container`}>
{bootstrapPackageView}
<BootstrapAdvancedOptions
currentTeamId={currentTeamId}
enableInstallManually={!noPackageUploaded}
selectManualAgentInstall={selectedManualAgentInstall}
onChange={(manualAgentInstall) => {
setSelectedManualAgentInstall(manualAgentInstall);
}}
/>
</div>
<div className={`${baseClass}__preview-container`}>
<BootstrapPackagePreview />
</div>
</SetupExperienceContentContainer>
);
};
return (
<div className={baseClass}>
<section className={baseClass}>
<SectionHeader title="Bootstrap package" />
{isLoading ? (
{isLoading || isLoadingGlobalConfig || isLoadingTeamConfig ? (
<Spinner />
) : (
<div className={`${baseClass}__content`}>
{noPackageUploaded ? (
<>
<PackageUploader
currentTeamId={currentTeamId}
onUpload={onUpload}
/>
<div className={`${baseClass}__preview-container`}>
<BootstrapPackagePreview />
</div>
</>
) : (
<>
<UploadedPackageView
bootstrapPackage={bootstrapMetadata}
currentTeamId={currentTeamId}
onDelete={() => setShowDeletePackageModal(true)}
/>
<div className={`${baseClass}__preview-container`}>
<BootstrapPackagePreview />
</div>
</>
)}
</div>
renderBootstrapView()
)}
{showDeletePackageModal && (
<DeletePackageModal
{showDeleteBootstrapPackageModal && (
<DeleteBootstrapPackageModal
onDelete={onDelete}
onCancel={() => setShowDeletePackageModal(false)}
onCancel={() => setShowDeleteBootstrapPackageModal(false)}
/>
)}
</div>
</section>
);
};

View file

@ -1,15 +1,7 @@
.bootstrap-package {
&__content {
max-width: $break-xxl;
margin: 0 auto;
&__uploader-container {
display: flex;
justify-content: space-between;
gap: $pad-xxlarge;
}
@media (max-width: $break-md) {
&__content {
flex-direction: column;
}
flex-direction: column;
gap: $pad-large;
}
}

View file

@ -0,0 +1,93 @@
import React, { useContext, useState } from "react";
import mdmAPI from "services/entities/mdm";
import { NotificationContext } from "context/notification";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "bootstrap-advanced-options";
interface IBootstrapAdvancedOptionsProps {
currentTeamId: number;
enableInstallManually: boolean;
selectManualAgentInstall: boolean;
onChange: (value: boolean) => void;
}
const BootstrapAdvancedOptions = ({
currentTeamId,
enableInstallManually,
selectManualAgentInstall,
onChange,
}: IBootstrapAdvancedOptionsProps) => {
const { renderFlash } = useContext(NotificationContext);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await mdmAPI.updateSetupExperienceSettings({
team_id: currentTeamId,
manual_agent_install: selectManualAgentInstall,
});
renderFlash("success", "Successfully updated.");
} catch {
renderFlash("error", "Something went wrong. Please try again.");
}
};
const tooltip = (
<>
Use this option if you&apos;re deploying a custom fleetd via bootstrap
package. If enabled, Fleet won&apos;t install fleetd automatically. To use
this option upload a bootstrap package first.
</>
);
return (
<div className={baseClass}>
<RevealButton
className={`${baseClass}__accordion-title`}
isShowing={showAdvancedOptions}
showText="Show advanced options"
hideText="Hide advanced options"
caretPosition="after"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
/>
{showAdvancedOptions && (
<form onSubmit={onSubmit}>
<GitOpsModeTooltipWrapper
renderChildren={(gitopsDisable) => (
<div className={`${baseClass}__advanced-options-controls`}>
<Checkbox
value={selectManualAgentInstall}
onChange={onChange}
disabled={gitopsDisable || !enableInstallManually}
>
<TooltipWrapper
tipContent={tooltip}
disableTooltip={gitopsDisable}
>
Install Fleet&apos;s agent (fleetd) manually
</TooltipWrapper>
</Checkbox>
<Button
disabled={gitopsDisable || !enableInstallManually}
type="submit"
>
Save
</Button>
</div>
)}
/>
</form>
)}
</div>
);
};
export default BootstrapAdvancedOptions;

View file

@ -0,0 +1,20 @@
.bootstrap-advanced-options {
display: flex;
flex-direction: column;
gap: $pad-large;
&__accordion-title {
// use this so we dont center the text.
justify-content: normal;
}
&__advanced-options-controls {
display: flex;
flex-direction: column;
gap: $pad-large;
button {
align-self: flex-start;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./BootstrapAdvancedOptions";

View file

@ -8,6 +8,7 @@ import endpoints from "utilities/endpoints";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import Graphic from "components/Graphic";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "bootstrap-package-list-item";
@ -82,13 +83,18 @@ const BootstrapPackageListItem = ({
url={url}
token={bootstrapPackage.token}
/>
<Button
className={`${baseClass}__list-item-button`}
variant="text-icon"
onClick={() => onDelete(bootstrapPackage)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
<GitOpsModeTooltipWrapper
renderChildren={(disabled) => (
<Button
className={`${baseClass}__list-item-button`}
variant="text-icon"
disabled={disabled}
onClick={() => onDelete(bootstrapPackage)}
>
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
)}
/>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
import React from "react";
import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif";
import BootstrapPackageEndUserPreview from "../../../../../../../../assets/videos/bootstrap-package-preview.mp4";
const baseClass = "bootstrap-package-preview";
@ -9,20 +9,17 @@ const BootstrapPackagePreview = () => {
<div className={baseClass}>
<h3>End user experience</h3>
<p>
The bootstrap package is automatically installed after the end user
authenticates and agrees to the EULA during the <b>Remote Management</b>{" "}
screen in macOS Setup Assistant.
The bootstrap package is installed after the end user authenticates and
agrees to the EULA.
</p>
<p>
The end user is allowed to continue to the next setup screen before the
installation starts.
</p>
<p>The package isn&apos;t installed on hosts that already enrolled.</p>
<img
className={`${baseClass}__preview-img`}
src={OsSetupPreview}
alt="End user experience during the macOS setup assistant with the
bootstrap package installation"
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={`${baseClass}__preview-video`}
src={BootstrapPackageEndUserPreview}
controls
autoPlay
loop
muted
/>
</div>
);

View file

@ -11,7 +11,7 @@
font-weight: normal;
}
&__preview-img {
&__preview-video {
width: 100%;
display: block;
margin: $pad-xxlarge auto 0;

View file

@ -43,28 +43,28 @@ const BootstrapPackageTable = ({
if (isError) return <DataError />;
return (
<div className={baseClass}>
<TableContainer
columnConfigs={COLUMN_CONFIGS}
data={tableData}
resultsTitle=""
isLoading={isLoading}
showMarkAllPages={false}
isAllPagesSelected={false}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
disableTableHeader
disablePagination
disableCount
emptyComponent={() => (
<EmptyTable
header="No bootstrap package status"
info="Expecting to status data? Try again in a few seconds as the system
<TableContainer
className={baseClass}
columnConfigs={COLUMN_CONFIGS}
data={tableData}
resultsTitle=""
isLoading={isLoading}
showMarkAllPages={false}
isAllPagesSelected={false}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
disableTableHeader
disablePagination
disableCount
hideFooter
emptyComponent={() => (
<EmptyTable
header="No bootstrap package status"
info="Expecting to status data? Try again in a few seconds as the system
catches up."
/>
)}
/>
</div>
/>
)}
/>
);
};

View file

@ -1,13 +1,17 @@
.bootstrap-package-table {
padding: $pad-xxlarge;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius;
// keep a minimum height to prevent UI jumping when the table data is done
// loading.
min-height: 191px;
.data-table-block .data-table tbody td .w250 {
min-width: auto;
// some data table overrides to display the table correctly
.data-table-block .data-table {
tbody td .w250 {
min-width: auto;
}
&__wrapper {
margin-top: 0;
}
}
@media (max-width: $break-lg) {

View file

@ -3,17 +3,17 @@ import React from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
interface DeletePackageModalProps {
interface DeleteBootstrapPackageModalProps {
onCancel: () => void;
onDelete: () => void;
}
const baseClass = "delete-package-modal";
const baseClass = "delete-bootstrap-package-modal";
const DeletePackageModal = ({
const DeleteBootstrapPackageModal = ({
onCancel,
onDelete,
}: DeletePackageModalProps) => {
}: DeleteBootstrapPackageModalProps) => {
return (
<Modal
className={baseClass}
@ -22,10 +22,14 @@ const DeletePackageModal = ({
onEnter={() => onDelete()}
>
<>
<p>Delete the bootstrap package to upload a new one.</p>
<p>
If you need to remove the package from macOS hosts already enrolled,
use your configuration management tool (ex. Munki, Chef, or Puppet).
Package won&apos;t be uninstalled from existing macOS hosts. Installs
or uninstalls currently running on a host will still complete.
</p>
<p>
Option to install Fleet&apos;s agent (fleetd) manually will be
disabled, so agent will be installed automatically during automatic
enollment of macOS hosts.
</p>
<div className="modal-cta-wrap">
<Button type="button" onClick={() => onDelete()} variant="alert">
@ -40,4 +44,4 @@ const DeletePackageModal = ({
);
};
export default DeletePackageModal;
export default DeleteBootstrapPackageModal;

View file

@ -0,0 +1 @@
export { default } from "./DeleteBootstrapPackageModal";

View file

@ -1 +0,0 @@
export { default } from "./DeletePackageModal";

View file

@ -8,8 +8,4 @@
font-size: $x-small;
margin: 0;
}
.upload-list__list-item {
border-top: 1px solid $ui-fleet-black-10;
}
}

View file

@ -14,6 +14,7 @@ import Spinner from "components/Spinner";
import RequireEndUserAuth from "./components/RequireEndUserAuth/RequireEndUserAuth";
import EndUserAuthForm from "./components/EndUserAuthForm/EndUserAuthForm";
import EndUserExperiencePreview from "./components/EndUserExperiencePreview";
import SetupExperienceContentContainer from "../../components/SetupExperienceContentContainer";
const baseClass = "end-user-authentication";
@ -83,12 +84,12 @@ const EndUserAuthentication = ({
};
return (
<div className={baseClass}>
<section className={baseClass}>
<SectionHeader title="End user authentication" />
{isLoadingGlobalConfig || isLoadingTeamConfig ? (
<Spinner />
) : (
<div className={`${baseClass}__content`}>
<SetupExperienceContentContainer>
{!globalConfig || !isIdPConfigured(globalConfig.mdm) ? (
<RequireEndUserAuth onClickConnect={onClickConnect} />
) : (
@ -98,9 +99,9 @@ const EndUserAuthentication = ({
/>
)}
<EndUserExperiencePreview />
</div>
</SetupExperienceContentContainer>
)}
</div>
</section>
);
};

View file

@ -1,15 +0,0 @@
.end-user-authentication {
&__content {
max-width: $break-xxl;
margin: 0 auto;
display: flex;
justify-content: space-between;
gap: $pad-xxlarge;
}
@media (max-width: $break-md) {
&__content {
flex-direction: column;
}
}
}

View file

@ -1,7 +1,7 @@
import React from "react";
import classnames from "classnames";
import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif";
import EndUserAuthEndUserPreview from "../../../../../../../../assets/videos/end-user-auth-preview.mp4";
const baseClass = "end-user-experience-preview";
@ -18,19 +18,18 @@ const EndUserExperiencePreview = ({
<div className={classes}>
<h3>End user experience</h3>
<p>
When the end user reaches the <b>Remote Management</b> screen in the
macOS Setup Assistant, they are asked to authenticate and agree to the
end user license agreement (EULA).
When the end user reaches the <b>Remote Management</b> screen, they are
first asked to authenticate and agree to the end user license agreement
(EULA).
</p>
<p>
After, Fleet enrolls the Mac, applies macOS settings, and installs the
bootstrap package.
</p>
<img
className={`${baseClass}__preview-img`}
src={OsSetupPreview}
alt="End user experience during the macOS setup assistant with the user
logging in with their IdP provider"
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={`${baseClass}__preview-video`}
src={EndUserAuthEndUserPreview}
controls
autoPlay
loop
muted
/>
</div>
);

View file

@ -11,7 +11,7 @@
font-weight: normal;
}
&__preview-img {
&__preview-video {
width: 100%;
display: block;
margin: $pad-xxlarge auto 0;

View file

@ -5,8 +5,12 @@ import { AxiosError } from "axios";
import mdmAPI, {
IGetSetupExperienceSoftwareResponse,
} from "services/entities/mdm";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { ISoftwareTitle } from "interfaces/software";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { IConfig } from "interfaces/config";
import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team";
import SectionHeader from "components/SectionHeader";
import DataError from "components/DataError";
@ -15,6 +19,8 @@ import Spinner from "components/Spinner";
import InstallSoftwarePreview from "./components/InstallSoftwarePreview";
import AddInstallSoftware from "./components/AddInstallSoftware";
import SelectSoftwareModal from "./components/SelectSoftwareModal";
import SetupExperienceContentContainer from "../../components/SetupExperienceContentContainer";
import { getManualAgentInstallSetting } from "../BootstrapPackage/BootstrapPackage";
const baseClass = "install-software";
@ -51,13 +57,37 @@ const InstallSoftware = ({ currentTeamId }: IInstallSoftwareProps) => {
}
);
const { data: globalConfig, isLoading: isLoadingGlobalConfig } = useQuery<
IConfig,
Error
>(["config", currentTeamId], () => configAPI.loadAll(), {
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId === API_NO_TEAM_ID,
});
const { data: teamConfig, isLoading: isLoadingTeamConfig } = useQuery<
ILoadTeamResponse,
Error,
ITeamConfig
>(["team", currentTeamId], () => teamsAPI.load(currentTeamId), {
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId !== API_NO_TEAM_ID,
select: (res) => res.team,
});
const onSave = async () => {
setShowSelectSoftwareModal(false);
refetchSoftwareTitles();
};
const hasManualAgentInstall = getManualAgentInstallSetting(
currentTeamId,
globalConfig,
teamConfig
);
const renderContent = () => {
if (isLoading) {
if (isLoading || isLoadingGlobalConfig || isLoadingTeamConfig) {
return <Spinner />;
}
@ -67,14 +97,15 @@ const InstallSoftware = ({ currentTeamId }: IInstallSoftwareProps) => {
if (softwareTitles || softwareTitles === null) {
return (
<div className={`${baseClass}__content`}>
<SetupExperienceContentContainer>
<AddInstallSoftware
currentTeamId={currentTeamId}
hasManualAgentInstall={hasManualAgentInstall}
softwareTitles={softwareTitles}
onAddSoftware={() => setShowSelectSoftwareModal(true)}
/>
<InstallSoftwarePreview />
</div>
</SetupExperienceContentContainer>
);
}
@ -82,7 +113,7 @@ const InstallSoftware = ({ currentTeamId }: IInstallSoftwareProps) => {
};
return (
<div className={baseClass}>
<section className={baseClass}>
<SectionHeader title="Install software" />
<>{renderContent()}</>
{showSelectSoftwareModal && softwareTitles && (
@ -93,7 +124,7 @@ const InstallSoftware = ({ currentTeamId }: IInstallSoftwareProps) => {
onExit={() => setShowSelectSoftwareModal(false)}
/>
)}
</div>
</section>
);
};

View file

@ -1,15 +0,0 @@
.install-software {
&__content {
max-width: $break-xxl;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $pad-xxlarge;
}
@media (max-width: $break-md) {
&__content {
grid-template-columns: minmax(0, 1fr);
}
}
}

View file

@ -16,6 +16,7 @@ describe("AddInstallSoftware", () => {
currentTeamId={1}
softwareTitles={null}
onAddSoftware={noop}
hasManualAgentInstall={false}
/>
);
@ -29,6 +30,7 @@ describe("AddInstallSoftware", () => {
currentTeamId={1}
softwareTitles={[createMockSoftwareTitle(), createMockSoftwareTitle()]}
onAddSoftware={noop}
hasManualAgentInstall={false}
/>
);
@ -56,6 +58,7 @@ describe("AddInstallSoftware", () => {
createMockSoftwareTitle(),
]}
onAddSoftware={noop}
hasManualAgentInstall={false}
/>
);

View file

@ -9,17 +9,20 @@ import CustomLink from "components/CustomLink";
import { ISoftwareTitle } from "interfaces/software";
import LinkWithContext from "components/LinkWithContext";
import TooltipWrapper from "components/TooltipWrapper";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "add-install-software";
interface IAddInstallSoftwareProps {
currentTeamId: number;
hasManualAgentInstall: boolean;
softwareTitles: ISoftwareTitle[] | null;
onAddSoftware: () => void;
}
const AddInstallSoftware = ({
currentTeamId,
hasManualAgentInstall,
softwareTitles,
onAddSoftware,
}: IAddInstallSoftwareProps) => {
@ -79,6 +82,13 @@ const AddInstallSoftware = ({
const addedText = getAddedText();
const buttonText = getButtonText();
const manuallyInstallTooltipText = (
<>
Disabled because you manually install Fleet&apos;s agent (
<b>Bootstrap package {">"} Advanced options</b>). Use your bootstrap
package to install software during the setup experience.
</>
);
return (
<div className={baseClass}>
@ -94,13 +104,26 @@ const AddInstallSoftware = ({
</div>
<span className={`${baseClass}__added-text`}>{addedText}</span>
<div>
<Button
className={`${baseClass}__button`}
onClick={onAddSoftware}
disabled={hasNoSoftware}
>
{buttonText}
</Button>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<TooltipWrapper
className={`${baseClass}__manual-install-tooltip`}
tipContent={manuallyInstallTooltipText}
disableTooltip={disableChildren || !hasManualAgentInstall}
position="top"
showArrow
underline={false}
>
<Button
className={`${baseClass}__button`}
onClick={onAddSoftware}
disabled={disableChildren || hasManualAgentInstall}
>
{buttonText}
</Button>
</TooltipWrapper>
)}
/>
</div>
</div>
);

View file

@ -14,4 +14,8 @@
&__added-text {
font-weight: $bold;
}
&__manual-install-tooltip {
text-align: center;
}
}

View file

@ -2,7 +2,7 @@ import React from "react";
import Card from "components/Card";
import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png";
import InstallSoftwareEndUserPreview from "../../../../../../../../assets/videos/install-software-preview.mp4";
const baseClass = "install-software-preview";
@ -11,19 +11,22 @@ const InstallSoftwarePreview = () => {
<Card color="grey" paddingSize="xxlarge" className={baseClass}>
<h3>End user experience</h3>
<p>
After the <b>Remote Management</b> screen, the end user will see
software being installed. They will not be able to continue until
software is installed.
During the <b>Remote Management</b> screen, the end user will see
selected software being installed. They won&apos;t be able to continue
until software is installed.
</p>
<p>
If there are any errors, they will be able to continue and will be
instructed to contact their IT admin.
</p>
<img
className={`${baseClass}__preview-img`}
src={InstallSoftwarePreviewImg}
alt="End user experience during the macOS setup assistant with selected
software being installed"
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={`${baseClass}__preview-video`}
src={InstallSoftwareEndUserPreview}
controls
autoPlay
loop
muted
/>
</Card>
);

View file

@ -5,9 +5,10 @@
font-weight: normal;
}
&__preview-img {
&__preview-video {
margin-top: $pad-xxlarge;
width: 100%;
display: block;
border-radius: $border-radius-large;
}
}

View file

@ -8,14 +8,14 @@ import {
errorNoSetupExperienceScript,
} from "test/handlers/setup-experience-handlers";
import SetupExperienceScript from "./SetupExperienceScript";
import RunScript from "./RunScript";
describe("SetupExperienceScript", () => {
describe("RunScript", () => {
it("should render the script uploader when no script has been uploaded", async () => {
mockServer.use(errorNoSetupExperienceScript);
const render = createCustomRenderer({ withBackendMock: true });
render(<SetupExperienceScript currentTeamId={1} />);
render(<RunScript currentTeamId={1} />);
expect(await screen.findByRole("button", { name: "Upload" })).toBeVisible();
});
@ -24,7 +24,7 @@ describe("SetupExperienceScript", () => {
mockServer.use(defaultSetupExperienceScriptHandler);
const render = createCustomRenderer({ withBackendMock: true });
render(<SetupExperienceScript currentTeamId={1} />);
render(<RunScript currentTeamId={1} />);
expect(
await screen.findByText("Script will run during setup:")

View file

@ -9,27 +9,30 @@ import {
import mdmAPI, {
IGetSetupExperienceScriptResponse,
} from "services/entities/mdm";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { IConfig } from "interfaces/config";
import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team";
import SectionHeader from "components/SectionHeader";
import DataError from "components/DataError";
import Spinner from "components/Spinner";
import CustomLink from "components/CustomLink";
import SetupExperiencePreview from "./components/SetupExperienceScriptPreview";
import SetupExperienceScriptUploader from "./components/SetupExperienceScriptUploader";
import SetupExperienceScriptCard from "./components/SetupExperienceScriptCard";
import DeleteSetupExperienceScriptModal from "./components/DeleteSetupExperienceScriptModal";
import SetupExperienceContentContainer from "../../components/SetupExperienceContentContainer";
import { getManualAgentInstallSetting } from "../BootstrapPackage/BootstrapPackage";
const baseClass = "setup-experience-script";
const baseClass = "run-script";
interface ISetupExperienceScriptProps {
interface IRunScriptProps {
currentTeamId: number;
}
const SetupExperienceScript = ({
currentTeamId,
}: ISetupExperienceScriptProps) => {
const RunScript = ({ currentTeamId }: IRunScriptProps) => {
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const {
@ -45,6 +48,24 @@ const SetupExperienceScript = ({
{ ...DEFAULT_USE_QUERY_OPTIONS, retry: false }
);
const { data: globalConfig, isLoading: isLoadingGlobalConfig } = useQuery<
IConfig,
Error
>(["config", currentTeamId], () => configAPI.loadAll(), {
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId === API_NO_TEAM_ID,
});
const { data: teamConfig, isLoading: isLoadingTeamConfig } = useQuery<
ILoadTeamResponse,
Error,
ITeamConfig
>(["team", currentTeamId], () => teamsAPI.load(currentTeamId), {
...DEFAULT_USE_QUERY_OPTIONS,
enabled: currentTeamId !== API_NO_TEAM_ID,
select: (res) => res.team,
});
const onUpload = () => {
refetchScript();
};
@ -56,9 +77,14 @@ const SetupExperienceScript = ({
};
const scriptUploaded = true;
const hasManualAgentInstall = getManualAgentInstallSetting(
currentTeamId,
globalConfig,
teamConfig
);
const renderContent = () => {
if (isLoading) {
if (isLoading || isLoadingGlobalConfig || isLoadingTeamConfig) {
<Spinner />;
}
@ -67,7 +93,7 @@ const SetupExperienceScript = ({
}
return (
<div className={`${baseClass}__content`}>
<SetupExperienceContentContainer>
<div className={`${baseClass}__description-container`}>
<p className={`${baseClass}__description`}>
Upload a script to run on hosts that automatically enroll to Fleet.
@ -81,6 +107,7 @@ const SetupExperienceScript = ({
{!scriptUploaded || !script ? (
<SetupExperienceScriptUploader
currentTeamId={currentTeamId}
hasManualAgentInstall={hasManualAgentInstall}
onUpload={onUpload}
/>
) : (
@ -96,12 +123,12 @@ const SetupExperienceScript = ({
)}
</div>
<SetupExperiencePreview />
</div>
</SetupExperienceContentContainer>
);
};
return (
<div className={baseClass}>
<section className={baseClass}>
<SectionHeader title="Run script" />
<>{renderContent()}</>
{showDeleteScriptModal && script && (
@ -112,8 +139,8 @@ const SetupExperienceScript = ({
onExit={() => setShowDeleteScriptModal(false)}
/>
)}
</div>
</section>
);
};
export default SetupExperienceScript;
export default RunScript;

View file

@ -0,0 +1,14 @@
.run-script {
&__description {
margin: 0;
}
&__learn-how-link {
margin-bottom: $pad-large;
}
&__run-message {
margin: 0 0 $pad-small;
font-weight: $bold;
}
}

View file

@ -2,7 +2,7 @@ import React from "react";
import Card from "components/Card";
import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png";
import SetupExperienceRunScriptPreview from "../../../../../../../../assets/images/run-script-preview.png";
const baseClass = "setup-experience-script-preview";
@ -20,7 +20,7 @@ const SetupExperienceScriptPreview = () => {
</p>
<img
className={`${baseClass}__preview-img`}
src={InstallSoftwarePreviewImg}
src={SetupExperienceRunScriptPreview}
alt="End user experience during the macOS setup assistant with the uploaded
script being run"
/>

View file

@ -11,12 +11,14 @@ const baseClass = "setup-experience-script-uploader";
interface ISetupExperienceScriptUploaderProps {
currentTeamId: number;
hasManualAgentInstall: boolean;
onUpload: () => void;
className?: string;
}
const SetupExperienceScriptUploader = ({
currentTeamId,
hasManualAgentInstall,
onUpload,
className,
}: ISetupExperienceScriptUploaderProps) => {
@ -47,6 +49,14 @@ const SetupExperienceScriptUploader = ({
setShowLoading(false);
};
const manuallyInstallTooltipText = (
<>
Disabled because you manually install Fleet&apos;s agent (
<b>Bootstrap package {">"} Advanced options</b>). Use your bootstrap
package to install software during the setup experience.
</>
);
return (
<FileUploader
className={classNames}
@ -56,6 +66,8 @@ const SetupExperienceScriptUploader = ({
buttonMessage="Upload"
onFileUpload={onUploadFile}
isLoading={showLoading}
disabled={hasManualAgentInstall}
buttonTooltip={hasManualAgentInstall && manuallyInstallTooltipText}
gitopsCompatible
/>
);

View file

@ -0,0 +1 @@
export { default } from "./RunScript";

View file

@ -20,6 +20,7 @@ import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUpl
import SetupAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard";
import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile";
import AdvancedOptionsForm from "./components/AdvancedOptionsForm";
import SetupExperienceContentContainer from "../../components/SetupExperienceContentContainer";
const baseClass = "setup-assistant";
@ -90,12 +91,12 @@ const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => {
const enrollmentProfileNotFound = enrollmentProfileError?.status === 404;
return (
<div className={baseClass}>
<section className={baseClass}>
<SectionHeader title="Setup assistant" />
{isLoading ? (
<Spinner />
) : (
<div className={`${baseClass}__content`}>
<SetupExperienceContentContainer>
<div className={`${baseClass}__upload-container`}>
<p className={`${baseClass}__section-description`}>
Add an automatic enrollment profile to customize the macOS Setup
@ -126,7 +127,7 @@ const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => {
<div className={`${baseClass}__preview-container`}>
<SetupAssistantPreview />
</div>
</div>
</SetupExperienceContentContainer>
)}
{showDeleteProfileModal && (
<DeleteAutoEnrollmentProfile
@ -135,7 +136,7 @@ const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => {
onCancel={() => setShowDeleteProfileModal(false)}
/>
)}
</div>
</section>
);
};

View file

@ -1,12 +1,4 @@
.setup-assistant {
&__content {
max-width: $break-xxl;
margin: 0 auto;
display: flex;
justify-content: space-between;
gap: $pad-xxlarge;
}
&__upload-container {
display: flex;
flex-direction: column;
@ -16,10 +8,4 @@
&__section-description {
margin: 0;
}
@media (max-width: $break-md) {
&__content {
flex-direction: column;
}
}
}

View file

@ -2,7 +2,7 @@ import React from "react";
import Card from "components/Card";
import OsPrefillPreview from "../../../../../../../../assets/images/os-prefill-preview.gif";
import SetupAssistantEndUserPreview from "../../../../../../../../assets/videos/setup-assistant-preview.mp4";
const baseClass = "setup-assistant-preview";
@ -18,11 +18,14 @@ const SetupAssistantPreview = () => {
By adding an automatic enrollment profile you can customize which
screens are displayed and more.
</p>
<img
className={`${baseClass}__preview-img`}
src={OsPrefillPreview}
alt="End user experience during the macOS setup assistant customised by
an automatic enrollment profile"
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={`${baseClass}__preview-video`}
src={SetupAssistantEndUserPreview}
controls
autoPlay
loop
muted
/>
</Card>
);

View file

@ -7,7 +7,7 @@
font-weight: normal;
}
&__preview-img {
&__preview-video {
width: 100%;
display: block;
margin: $pad-xxlarge auto 0;

View file

@ -1,28 +0,0 @@
.setup-experience-script {
&__content {
max-width: $break-xxl;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $pad-xxlarge;
}
&__description {
margin: 0;
}
&__learn-how-link {
margin-bottom: $pad-large;
}
&__run-message {
margin: 0 0 $pad-small;
font-weight: $bold;
}
@media (max-width: $break-md) {
&__content {
grid-template-columns: minmax(0, 1fr);
}
}
}

View file

@ -1 +0,0 @@
export { default } from "./SetupExperienceScript";

View file

@ -0,0 +1,19 @@
import React from "react";
import classnames from "classnames";
const baseClass = "setup-experience-content-container";
interface ISetupExperienceContentContainerProps {
children: React.ReactNode;
className?: string;
}
const SetupExperienceContentContainer = ({
children,
className,
}: ISetupExperienceContentContainerProps) => {
const classNames = classnames(baseClass, className);
return <div className={classNames}>{children}</div>;
};
export default SetupExperienceContentContainer;

View file

@ -0,0 +1,10 @@
.setup-experience-content-container {
max-width: $break-xxl;
display: grid;
grid-template-columns: minmax(150px, 450px) minmax(50%, 1fr);
gap: $pad-xxlarge;
@media (max-width: $break-md) {
grid-template-columns: minmax(0, 1fr);
}
}

View file

@ -0,0 +1 @@
export { default } from "./SetupExperienceContentContainer";

View file

@ -48,7 +48,9 @@ export const isDDMProfile = (profile: IMdmProfile | IHostMdmProfile) => {
interface IUpdateSetupExperienceBody {
team_id?: number;
enable_release_device_manually: boolean;
enable_end_user_authentication?: boolean;
enable_release_device_manually?: boolean;
manual_agent_install?: boolean;
}
export interface IAppleSetupEnrollmentProfileResponse {
@ -248,6 +250,19 @@ const mdmService = {
});
},
updateSetupExperienceSettings: (updateData: IUpdateSetupExperienceBody) => {
const { MDM_SETUP_EXPERIENCE } = endpoints;
const body = {
...updateData,
};
if (updateData.team_id === API_NO_TEAM_ID) {
delete body.team_id;
}
return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body);
},
updateReleaseDeviceSetting: (teamId: number, isEnabled: boolean) => {
const { MDM_SETUP_EXPERIENCE } = endpoints;

View file

@ -22,6 +22,11 @@ declare module "*.pdf" {
export = value;
}
declare module "*.mp4" {
const value: string;
export = value;
}
declare const featureFlags: {
[key: string]: type;
};