add startup assistant to the UI (#17731)
relates to #9147 add the setup assistant page to the UI. This includes: - new setup assistant page - uploaded profile and release device manually form - preview for setup assistant flow <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
This commit is contained in:
co-authored by
Martin Angers
parent
8253e77264
commit
a292e704de
@@ -145,6 +145,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||
bootstrap_package: "",
|
||||
enable_end_user_authentication: false,
|
||||
macos_setup_assistant: null,
|
||||
enable_release_device_manually: false,
|
||||
},
|
||||
macos_migration: {
|
||||
enable: false,
|
||||
|
||||
@@ -8,14 +8,20 @@ type CardColor = "white" | "gray" | "purple" | "yellow";
|
||||
|
||||
interface ICardProps {
|
||||
children?: React.ReactNode;
|
||||
/** The size of the border radius. Defaults to `small` */
|
||||
/** The size of the border radius. Defaults to `small`. */
|
||||
borderRadiusSize?: BorderRadiusSize;
|
||||
/** Includes the card shadows. Defaults to `false` */
|
||||
includeShadow?: boolean;
|
||||
/** The color of the card. Defaults to `white` */
|
||||
color?: CardColor;
|
||||
className?: string;
|
||||
/** Increases to 40px padding. Defaults to `false` */
|
||||
/** The size of the padding around the content of the card. Defaults to `large`.
|
||||
*
|
||||
* These correspond to the padding sizes in the design system. Look at `padding.scss` for values */
|
||||
paddingSize?: "small" | "medium" | "large" | "xlarge" | "xxlarge";
|
||||
/** NOTE: DEPRICATED. Use `paddingSize` prop instead.
|
||||
*
|
||||
* Increases to 40px padding. Defaults to `false` */
|
||||
largePadding?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,12 +36,16 @@ const Card = ({
|
||||
color = "white",
|
||||
className,
|
||||
largePadding = false,
|
||||
paddingSize = "large",
|
||||
}: ICardProps) => {
|
||||
const classNames = classnames(
|
||||
baseClass,
|
||||
`${baseClass}__${color}`,
|
||||
`${baseClass}__radius-${borderRadiusSize}`,
|
||||
{
|
||||
// TODO: simplify this when we've replaced largePadding prop with paddingSize
|
||||
[`${baseClass}__padding-${paddingSize}`]:
|
||||
!largePadding && paddingSize !== undefined,
|
||||
[`${baseClass}__shadow`]: includeShadow,
|
||||
[`${baseClass}__large-padding`]: largePadding,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,29 @@
|
||||
box-shadow: $box-shadow;
|
||||
}
|
||||
|
||||
&__padding-small {
|
||||
padding: $pad-small;
|
||||
}
|
||||
|
||||
&__padding-medium {
|
||||
padding: $pad-medium;
|
||||
}
|
||||
|
||||
&__padding-large {
|
||||
padding: $pad-large;
|
||||
}
|
||||
|
||||
&__padding-xlarge {
|
||||
padding: $pad-xlarge;
|
||||
}
|
||||
|
||||
&__padding-xxlarge {
|
||||
padding: $pad-xxlarge;
|
||||
}
|
||||
|
||||
// 40px padding
|
||||
// TODO: remove when we've replaced all instances of largePadding with
|
||||
// paddingSize prop
|
||||
&__large-padding {
|
||||
padding: $pad-xxlarge;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ interface IFileUploaderProps {
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
|
||||
*/
|
||||
accept?: string;
|
||||
/** The text to display on the upload button */
|
||||
buttonMessage?: string;
|
||||
className?: string;
|
||||
onFileUpload: (files: FileList | null) => void;
|
||||
}
|
||||
@@ -45,6 +47,7 @@ const FileUploader = ({
|
||||
additionalInfo,
|
||||
isLoading = false,
|
||||
accept,
|
||||
buttonMessage = "Upload",
|
||||
className,
|
||||
onFileUpload,
|
||||
}: IFileUploaderProps) => {
|
||||
@@ -73,11 +76,11 @@ const FileUploader = ({
|
||||
variant="brand"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<label htmlFor="upload-profile">Upload</label>
|
||||
<label htmlFor="upload-file">{buttonMessage}</label>
|
||||
</Button>
|
||||
<input
|
||||
accept={accept}
|
||||
id="upload-profile"
|
||||
id="upload-file"
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
onFileUpload(e.target.files);
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
&__upload-button {
|
||||
margin-top: 8px;
|
||||
// we handle the padding in the label so the entire button is clickable
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
height: 36px;
|
||||
width: 78.2667px;
|
||||
padding: $pad-small $pad-medium;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface IMdmConfig {
|
||||
bootstrap_package: string | null;
|
||||
enable_end_user_authentication: boolean;
|
||||
macos_setup_assistant: string | null;
|
||||
enable_release_device_manually: boolean | null;
|
||||
};
|
||||
macos_migration: IMacOsMigrationSettings;
|
||||
windows_updates: {
|
||||
|
||||
@@ -56,7 +56,8 @@ export interface ITeam extends ITeamSummary {
|
||||
macos_setup: {
|
||||
bootstrap_package: string | null;
|
||||
enable_end_user_authentication: boolean;
|
||||
macos_setup_assistant: string | null; // TODO: types?
|
||||
macos_setup_assistant: string | null;
|
||||
enable_release_device_manually: boolean | null;
|
||||
};
|
||||
windows_updates: {
|
||||
deadline_days: number | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ISideNavItem } from "pages/admin/components/SideNav/SideNav";
|
||||
|
||||
import EndUserAuthentication from "./cards/EndUserAuthentication/EndUserAuthentication";
|
||||
import BootstrapPackage from "./cards/BootstrapPackage";
|
||||
import SetupAssistant from "./cards/SetupAssistant";
|
||||
|
||||
interface ISetupExperienceCardProps {
|
||||
currentTeamId?: number;
|
||||
@@ -25,6 +26,12 @@ const SETUP_EXPERIENCE_NAV_ITEMS: ISideNavItem<
|
||||
path: PATHS.CONTROLS_BOOTSTRAP_PACKAGE,
|
||||
Card: BootstrapPackage,
|
||||
},
|
||||
{
|
||||
title: "Setup assistant",
|
||||
urlSection: "setup-assistant",
|
||||
path: PATHS.CONTROLS_SETUP_ASSITANT,
|
||||
Card: SetupAssistant,
|
||||
},
|
||||
];
|
||||
|
||||
export default SETUP_EXPERIENCE_NAV_ITEMS;
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@
|
||||
font-size: $x-small;
|
||||
|
||||
h2 {
|
||||
font-size: $x-small; // needed to override global h2 style
|
||||
margin: 0;
|
||||
font-size: $small;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&__preview-img {
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { IConfig } from "interfaces/config";
|
||||
import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team";
|
||||
import configAPI from "services/entities/config";
|
||||
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
|
||||
import mdmAPI, {
|
||||
IAppleSetupEnrollmentProfileResponse,
|
||||
} from "services/entities/mdm";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
|
||||
import SectionHeader from "components/SectionHeader";
|
||||
import Spinner from "components/Spinner";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
import SetupAssistantPreview from "./components/SetupAssistantPreview";
|
||||
import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUploader";
|
||||
import SetuAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard";
|
||||
import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile";
|
||||
import AdvancedOptionsForm from "./components/AdvancedOptionsForm";
|
||||
|
||||
const baseClass = "setup-assistant";
|
||||
|
||||
interface ISetupAssistantProps {
|
||||
currentTeamId: number;
|
||||
}
|
||||
|
||||
const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => {
|
||||
const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false);
|
||||
|
||||
const { data: globalConfig, isLoading: isLoadingGlobalConfig } = useQuery<
|
||||
IConfig,
|
||||
Error
|
||||
>(["config", currentTeamId], () => configAPI.loadAll(), {
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
retry: false,
|
||||
enabled: currentTeamId === API_NO_TEAM_ID,
|
||||
});
|
||||
|
||||
const { data: teamConfig, isLoading: isLoadingTeamConfig } = useQuery<
|
||||
ILoadTeamResponse,
|
||||
Error,
|
||||
ITeamConfig
|
||||
>(["team", currentTeamId], () => teamsAPI.load(currentTeamId), {
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled: currentTeamId !== API_NO_TEAM_ID,
|
||||
select: (res) => res.team,
|
||||
});
|
||||
|
||||
const {
|
||||
data: enrollmentProfileData,
|
||||
isLoading: isLoadingEnrollmentProfile,
|
||||
error: enrollmentProfileError,
|
||||
refetch: refetchEnrollmentProfile,
|
||||
} = useQuery<IAppleSetupEnrollmentProfileResponse, AxiosError>(
|
||||
["enrollment_profile", currentTeamId],
|
||||
() => mdmAPI.getSetupEnrollmentProfile(currentTeamId),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
const getReleaseDeviceSetting = () => {
|
||||
if (currentTeamId === API_NO_TEAM_ID) {
|
||||
return (
|
||||
globalConfig?.mdm.macos_setup.enable_release_device_manually || false
|
||||
);
|
||||
}
|
||||
return teamConfig?.mdm?.macos_setup.enable_release_device_manually || false;
|
||||
};
|
||||
|
||||
const onUpload = () => {
|
||||
refetchEnrollmentProfile();
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
setShowDeleteProfileModal(false);
|
||||
refetchEnrollmentProfile();
|
||||
};
|
||||
|
||||
const defaultReleaseDeviceSetting = getReleaseDeviceSetting();
|
||||
|
||||
const isLoading =
|
||||
isLoadingGlobalConfig || isLoadingTeamConfig || isLoadingEnrollmentProfile;
|
||||
const enrollmentProfileNotFound = enrollmentProfileError?.status === 404;
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<SectionHeader title="Setup assistant" />
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={`${baseClass}__content`}>
|
||||
<div className={`${baseClass}__upload-container`}>
|
||||
<p className={`${baseClass}__section-description`}>
|
||||
Add an automatic enrollment profile to customize the macOS Setup
|
||||
Assistant.
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/learn-more-about/setup-assistant"
|
||||
text="Learn how"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
{enrollmentProfileNotFound || !enrollmentProfileData ? (
|
||||
<SetupAssistantProfileUploader
|
||||
currentTeamId={currentTeamId}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
) : (
|
||||
<SetuAssistantProfileCard
|
||||
profile={enrollmentProfileData}
|
||||
onDelete={() => setShowDeleteProfileModal(true)}
|
||||
/>
|
||||
)}
|
||||
<AdvancedOptionsForm
|
||||
key={String(defaultReleaseDeviceSetting)}
|
||||
currentTeamId={currentTeamId}
|
||||
defaultReleaseDevice={defaultReleaseDeviceSetting}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${baseClass}__preview-container`}>
|
||||
<SetupAssistantPreview />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDeleteProfileModal && (
|
||||
<DeleteAutoEnrollmentProfile
|
||||
currentTeamId={currentTeamId}
|
||||
onDelete={onDelete}
|
||||
onCancel={() => setShowDeleteProfileModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupAssistant;
|
||||
@@ -0,0 +1,25 @@
|
||||
.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;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $break-md) {
|
||||
&__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
|
||||
import mdmAPI from "services/entities/mdm";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import Button from "components/buttons/Button";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
|
||||
const baseClass = "advanced-options-form";
|
||||
|
||||
interface IAdvancedOptionsFormProps {
|
||||
currentTeamId: number;
|
||||
defaultReleaseDevice: boolean;
|
||||
}
|
||||
|
||||
const AdvancedOptionsForm = ({
|
||||
currentTeamId,
|
||||
defaultReleaseDevice,
|
||||
}: IAdvancedOptionsFormProps) => {
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [releaseDevice, setReleaseDevice] = useState(defaultReleaseDevice);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await mdmAPI.updateReleaseDeviceSetting(currentTeamId, releaseDevice);
|
||||
renderFlash("success", "Successfully updated.");
|
||||
} catch {
|
||||
renderFlash("error", "Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const tooltip = (
|
||||
<>
|
||||
When enabled, you're responsible for sending the DeviceConfigured
|
||||
command. (Default: <b>Off</b>)
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<RevealButton
|
||||
className={`${baseClass}__accordion-title`}
|
||||
isShowing={showAdvancedOptions}
|
||||
showText="Hide advanced options"
|
||||
hideText="Show advanced options"
|
||||
caretPosition="after"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
/>
|
||||
{showAdvancedOptions && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Checkbox
|
||||
value={releaseDevice}
|
||||
onChange={() => setReleaseDevice(!releaseDevice)}
|
||||
>
|
||||
<TooltipWrapper tipContent={tooltip}>
|
||||
Release device manually
|
||||
</TooltipWrapper>
|
||||
</Checkbox>
|
||||
<Button type="submit">Save</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedOptionsForm;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.advanced-options-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
&__accordion-title {
|
||||
// use this so we dont center the text.
|
||||
justify-content: normal;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./AdvancedOptionsForm";
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import mdmAPI from "services/entities/mdm";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
interface DeleteAutoEnrollProfileProps {
|
||||
currentTeamId: number;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const baseClass = "delete-auto-enrollment-profile-modal";
|
||||
|
||||
const DeleteAutoEnrollProfile = ({
|
||||
currentTeamId,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: DeleteAutoEnrollProfileProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await mdmAPI.deleteSetupEnrollmentProfile(currentTeamId);
|
||||
renderFlash("success", "Successfully deleted!");
|
||||
} catch {
|
||||
renderFlash("error", "Couldn’t delete. Please try again.");
|
||||
}
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title="Delete automatic enrollment profile"
|
||||
onExit={onCancel}
|
||||
>
|
||||
<>
|
||||
<p>Delete the automatic enrollment profile to upload a new one.</p>
|
||||
<p>
|
||||
Without an automatic enrollment profile, new macOS hosts will
|
||||
automatically enroll with the default setup settings.
|
||||
</p>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="button" onClick={handleDelete} variant="alert">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="inverse-alert">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAutoEnrollProfile;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./DeleteAutoEnrollmentProfile";
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import Card from "components/Card";
|
||||
|
||||
import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif";
|
||||
|
||||
const baseClass = "setup-assistant-preview";
|
||||
|
||||
const SetupAssistantPreview = () => {
|
||||
return (
|
||||
<Card
|
||||
color="gray"
|
||||
borderRadiusSize="medium"
|
||||
paddingSize="xxlarge"
|
||||
className={baseClass}
|
||||
>
|
||||
<h2>End user experience</h2>
|
||||
<p>
|
||||
After the end user continues past the <b>Remote Management</b> screen,
|
||||
macOS Setup Assistant displays several screens by default.
|
||||
</p>
|
||||
<p>
|
||||
By adding an automatic enrollment profile you can customize which
|
||||
screens are displayed and more.
|
||||
</p>
|
||||
<img
|
||||
className={`${baseClass}__preview-img`}
|
||||
src={OsSetupPreview}
|
||||
alt="OS setup preview"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupAssistantPreview;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
.setup-assistant-preview {
|
||||
font-size: $x-small;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: $small;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&__preview-img {
|
||||
margin-top: $pad-xxlarge;
|
||||
width: 100%;
|
||||
display: block;
|
||||
margin: 40px auto 0;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./SetupAssistantPreview";
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import FileSaver from "file-saver";
|
||||
|
||||
import { uploadedFromNow } from "utilities/date_format";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
import Card from "components/Card";
|
||||
import Graphic from "components/Graphic";
|
||||
import Button from "components/buttons/Button";
|
||||
import { IAppleSetupEnrollmentProfileResponse } from "services/entities/mdm";
|
||||
|
||||
const baseClass = "setup-assistant-profile-card";
|
||||
interface ISetupAssistantProfileCardProps {
|
||||
profile: IAppleSetupEnrollmentProfileResponse;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const SetupAssistantProfileCard = ({
|
||||
profile,
|
||||
onDelete,
|
||||
}: ISetupAssistantProfileCardProps) => {
|
||||
const onDownload = () => {
|
||||
const date = new Date();
|
||||
const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${
|
||||
profile.name
|
||||
}`;
|
||||
const file = new global.window.File(
|
||||
[JSON.stringify(profile.enrollment_profile)],
|
||||
filename
|
||||
);
|
||||
|
||||
FileSaver.saveAs(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card paddingSize="medium" className={baseClass}>
|
||||
<Graphic name="file-configuration-profile" />
|
||||
<div className={`${baseClass}__info`}>
|
||||
<span className={`${baseClass}__profile-name`}>{profile.name}</span>
|
||||
<span className={`${baseClass}__uploaded-at`}>
|
||||
{uploadedFromNow(profile.uploaded_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${baseClass}__actions`}>
|
||||
<Button
|
||||
className={`${baseClass}__download-button`}
|
||||
variant="text-icon"
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Icon name="download" />
|
||||
</Button>
|
||||
<Button
|
||||
className={`${baseClass}__delete-button`}
|
||||
variant="text-icon"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Icon name="trash" color="ui-fleet-black-75" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupAssistantProfileCard;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.setup-assistant-profile-card {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
align-items: center;
|
||||
|
||||
// TODO: create reusable list item component and use instead of all these styles.
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__profile-name {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
&__uploaded-at {
|
||||
font-size: $xx-small;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__download-button, &__delete-button {
|
||||
padding: 11px; // TODO: use a padding value from existing variables. talk to design.
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./SetupAssistantProfileCard";
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
import { IApiError } from "interfaces/errors";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import mdmAPI from "services/entities/mdm";
|
||||
|
||||
import FileUploader from "components/FileUploader";
|
||||
|
||||
import { getErrorMessage } from "./helpers";
|
||||
|
||||
const baseClass = "setup-assistant-profile-uploader";
|
||||
|
||||
interface ISetupAssistantProfileUploaderProps {
|
||||
currentTeamId: number;
|
||||
onUpload: () => void;
|
||||
}
|
||||
|
||||
const SetupAssistantProfileUploader = ({
|
||||
currentTeamId,
|
||||
onUpload,
|
||||
}: ISetupAssistantProfileUploaderProps) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const onUploadFile = async (files: FileList | null) => {
|
||||
setShowLoading(true);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
setShowLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
await mdmAPI.uploadSetupEnrollmentProfile(file, currentTeamId);
|
||||
renderFlash("success", "Successfully uploaded!");
|
||||
onUpload();
|
||||
} catch (e) {
|
||||
const error = e as AxiosResponse<IApiError>;
|
||||
const errMessage = getErrorMessage(error);
|
||||
renderFlash("error", errMessage);
|
||||
} finally {
|
||||
setShowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FileUploader
|
||||
message="Automatic enrollment profile (.json)"
|
||||
graphicName="file-configuration-profile"
|
||||
accept=".json"
|
||||
buttonMessage="Add profile"
|
||||
onFileUpload={onUploadFile}
|
||||
isLoading={showLoading}
|
||||
className={baseClass}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupAssistantProfileUploader;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { getErrorReason } from "interfaces/errors";
|
||||
|
||||
const UPLOAD_ERROR_MESSAGES = {
|
||||
default: {
|
||||
message: "Couldn't upload. Please try again.",
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getErrorMessage = (err: unknown) => {
|
||||
if (typeof err === "string") return err;
|
||||
return getErrorReason(err) || UPLOAD_ERROR_MESSAGES.default.message;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./SetupAssistantProfileUploader";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SetupAssistant";
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
CONTROLS_SETUP_EXPERIENCE: `${URL_PREFIX}/controls/setup-experience`,
|
||||
CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`,
|
||||
CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`,
|
||||
CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`,
|
||||
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
|
||||
|
||||
DASHBOARD: `${URL_PREFIX}/dashboard`,
|
||||
|
||||
@@ -57,8 +57,8 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* updateMDMConfig is a special case of update that is used to update the MDM
|
||||
* config.
|
||||
* updateMDMConfig is a special case of update that is used to update the app
|
||||
* MDM config.
|
||||
*
|
||||
* If the request fails and `skipParseError` is `true`, the caller is
|
||||
* responsible for verifying that the value of the rejected promise is an AxiosError
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IMdmProfile,
|
||||
MdmProfileStatus,
|
||||
} from "interfaces/mdm";
|
||||
import { API_NO_TEAM_ID } from "interfaces/team";
|
||||
import sendRequest from "services";
|
||||
import endpoints from "utilities/endpoints";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
@@ -46,6 +47,19 @@ export interface IUploadProfileApiParams {
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
interface IUpdateSetupExperienceBody {
|
||||
team_id?: number;
|
||||
enable_release_device_manually: boolean;
|
||||
}
|
||||
|
||||
export interface IAppleSetupEnrollmentProfileResponse {
|
||||
team_id: number | null;
|
||||
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>;
|
||||
}
|
||||
|
||||
const mdmService = {
|
||||
downloadDeviceUserEnrollmentProfile: (token: string) => {
|
||||
const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints;
|
||||
@@ -226,6 +240,68 @@ const mdmService = {
|
||||
enable_end_user_authentication: isEnabled,
|
||||
});
|
||||
},
|
||||
|
||||
updateReleaseDeviceSetting: (teamId: number, isEnabled: boolean) => {
|
||||
const { MDM_SETUP_EXPERIENCE } = endpoints;
|
||||
|
||||
const body: IUpdateSetupExperienceBody = {
|
||||
enable_release_device_manually: isEnabled,
|
||||
};
|
||||
|
||||
if (teamId !== API_NO_TEAM_ID) {
|
||||
body.team_id = teamId;
|
||||
}
|
||||
|
||||
return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body);
|
||||
},
|
||||
getSetupEnrollmentProfile: (teamId?: number) => {
|
||||
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
|
||||
if (!teamId || teamId === API_NO_TEAM_ID) {
|
||||
return sendRequest("GET", MDM_APPLE_SETUP_ENROLLMENT_PROFILE);
|
||||
}
|
||||
|
||||
const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams(
|
||||
{ team_id: teamId }
|
||||
)}`;
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
uploadSetupEnrollmentProfile: (file: File, teamId: number) => {
|
||||
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.addEventListener("load", () => {
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
name: file.name,
|
||||
enrollment_profile: JSON.parse(reader.result as string),
|
||||
};
|
||||
if (teamId !== API_NO_TEAM_ID) {
|
||||
body.team_id = teamId;
|
||||
}
|
||||
resolve(
|
||||
sendRequest("POST", MDM_APPLE_SETUP_ENROLLMENT_PROFILE, body)
|
||||
);
|
||||
} catch {
|
||||
// catches invalid JSON
|
||||
reject("Couldn’t upload. The file should include valid JSON.");
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteSetupEnrollmentProfile: (teamId: number) => {
|
||||
const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints;
|
||||
if (teamId === API_NO_TEAM_ID) {
|
||||
return sendRequest("DELETE", MDM_APPLE_SETUP_ENROLLMENT_PROFILE);
|
||||
}
|
||||
|
||||
const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams(
|
||||
{ team_id: teamId }
|
||||
)}`;
|
||||
return sendRequest("DELETE", path);
|
||||
},
|
||||
};
|
||||
|
||||
export default mdmService;
|
||||
|
||||
@@ -139,6 +139,16 @@ export default {
|
||||
|
||||
return sendRequest("PATCH", path, requestBody);
|
||||
},
|
||||
|
||||
/**
|
||||
* updates the team config. This can take any partial data that is in the team config.
|
||||
*/
|
||||
updateConfig: (data: any, teamId: number): Promise<ITeamConfig> => {
|
||||
const { TEAMS } = endpoints;
|
||||
const path = `${TEAMS}/${teamId}`;
|
||||
return sendRequest("PATCH", path, data);
|
||||
},
|
||||
|
||||
addUsers: (teamId: number | undefined, newUsers: INewTeamUsersBody) => {
|
||||
if (!teamId || teamId <= API_NO_TEAM_ID) {
|
||||
return Promise.reject(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { uploadedFromNow } from ".";
|
||||
|
||||
describe("date_format", () => {
|
||||
describe("uploadedFromNow util", () => {
|
||||
it("returns an user friendly uploaded at message", () => {
|
||||
const currentDate = new Date();
|
||||
currentDate.setDate(currentDate.getDate() - 2);
|
||||
const twoDaysAgo = currentDate.toISOString();
|
||||
|
||||
expect(uploadedFromNow(twoDaysAgo)).toEqual("Uploaded 2 days ago");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const uploadedFromNow = (date: string) => {
|
||||
return `Uploaded ${formatDistanceToNow(new Date(date))} ago`;
|
||||
};
|
||||
@@ -79,11 +79,13 @@ export default {
|
||||
}
|
||||
return `/api/mdm/apple/enroll?${query}`;
|
||||
},
|
||||
MDM_APPLE_SETUP_ENROLLMENT_PROFILE: `/${API_VERSION}/fleet/mdm/apple/enrollment_profile`,
|
||||
MDM_BOOTSTRAP_PACKAGE_METADATA: (teamId: number) =>
|
||||
`/${API_VERSION}/fleet/mdm/bootstrap/${teamId}/metadata`,
|
||||
MDM_BOOTSTRAP_PACKAGE: `/${API_VERSION}/fleet/mdm/bootstrap`,
|
||||
MDM_BOOTSTRAP_PACKAGE_SUMMARY: `/${API_VERSION}/fleet/mdm/bootstrap/summary`,
|
||||
MDM_SETUP: `/${API_VERSION}/fleet/mdm/apple/setup`,
|
||||
MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`,
|
||||
MDM_EULA: (token: string) => `/${API_VERSION}/fleet/mdm/setup/eula/${token}`,
|
||||
MDM_EULA_UPLOAD: `/${API_VERSION}/fleet/mdm/setup/eula`,
|
||||
MDM_EULA_METADATA: `/${API_VERSION}/fleet/mdm/setup/eula/metadata`,
|
||||
|
||||
Reference in New Issue
Block a user