diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index c6ac93074c..e4b6ae2b57 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -73,6 +73,7 @@ type InitialStateType = { isSandboxMode?: boolean; isFreeTier?: boolean; isPremiumTier?: boolean; + isMdmEnabled?: boolean; isGlobalAdmin?: boolean; isGlobalMaintainer?: boolean; isGlobalObserver?: boolean; @@ -109,6 +110,7 @@ export const initialState = { isSandboxMode: false, isFreeTier: undefined, isPremiumTier: undefined, + isMdmEnabled: undefined, isGlobalAdmin: undefined, isGlobalMaintainer: undefined, isGlobalObserver: undefined, @@ -151,6 +153,8 @@ const setPermissions = ( isSandboxMode: permissions.isSandboxMode(config), isFreeTier: permissions.isFreeTier(config), isPremiumTier: permissions.isPremiumTier(config), + // isMdmEnabled: permissions.isMdmEnabled(config), + isMdmEnabled: true, // MDM TODO: Remove when backend is merged isGlobalAdmin: permissions.isGlobalAdmin(user), isGlobalMaintainer: permissions.isGlobalMaintainer(user), isGlobalObserver: permissions.isGlobalObserver(user), @@ -255,6 +259,7 @@ const AppProvider = ({ children }: Props): JSX.Element => { isSandboxMode: state.isSandboxMode, isFreeTier: state.isFreeTier, isPremiumTier: state.isPremiumTier, + isMdmEnabled: state.isMdmEnabled, isGlobalAdmin: state.isGlobalAdmin, isGlobalMaintainer: state.isGlobalMaintainer, isGlobalObserver: state.isGlobalObserver, diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index e9105fbdf0..2f8f878e92 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -212,6 +212,7 @@ export interface IConfig { }; }; }; + mdm_enabled?: boolean; } export interface IWebhookSettings { diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index 1352d3556b..2047789f07 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -1,3 +1,18 @@ +export interface IMdmApple { + common_name: string; + serial_number: string; + issuer: string; + renew_date: string; +} + +export interface IMdmAppleBm { + default_team?: string; + apple_id: string; + organization_name: string; + mdm_server_url: string; + renew_date: string; +} + export interface IMdmEnrollmentCardData { status: "Enrolled (manual)" | "Enrolled (automatic)" | "Unenrolled"; hosts: number; diff --git a/frontend/pages/admin/AppSettingsPage/components/OrgSettingsForm/OrgSettingsForm.tsx b/frontend/pages/admin/AppSettingsPage/components/OrgSettingsForm/OrgSettingsForm.tsx index f518ffbc54..7b45dc96f1 100644 --- a/frontend/pages/admin/AppSettingsPage/components/OrgSettingsForm/OrgSettingsForm.tsx +++ b/frontend/pages/admin/AppSettingsPage/components/OrgSettingsForm/OrgSettingsForm.tsx @@ -184,6 +184,7 @@ const OrgSettingsForm = ({ handleSubmit={onFormSubmit} isUpdatingSettings={isUpdatingSettings} /> + // // MDM TODO: Uncomment this as a shortcut to view MDM page before Gabe's work is done )} ); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Mdm/Mdm.tsx b/frontend/pages/admin/IntegrationsPage/cards/Mdm/Mdm.tsx new file mode 100644 index 0000000000..eb27fdb9f6 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Mdm/Mdm.tsx @@ -0,0 +1,293 @@ +import React, { useContext, useState } from "react"; +import { useQuery } from "react-query"; +import FileSaver from "file-saver"; + +import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; + +import mdmAppleAPI from "services/entities/mdm_apple"; +import mdmAppleBmAPI from "services/entities/mdm_apple_bm"; +import { IMdmApple, IMdmAppleBm } from "interfaces/mdm"; + +import Button from "components/buttons/Button"; +import CustomLink from "components/CustomLink"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; +import Icon from "components/Icon"; +import TooltipWrapper from "components/TooltipWrapper"; + +import RequestModal from "./components/RequestModal"; +import EditTeamModal from "./components/EditTeamModal"; + +// MDM TODO: key validation? +// import { isValidKeys } from "../../.."; + +const baseClass = "mdm-integrations"; + +const readableDate = (date: string) => { + const dateString = new Date(date); + + return new Intl.DateTimeFormat(navigator.language, { + year: "numeric", + month: "long", + day: "numeric", + }).format(dateString); +}; + +const Mdm = (): JSX.Element => { + const { isPremiumTier } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + const [showRequestModal, setShowRequestModal] = useState(false); + const [showEditTeamModal, setShowEditTeamModal] = useState(false); + + const { + data: mdmApple, + isLoading: isLoadingMdmApple, + error: errorMdmApple, + } = useQuery( + ["mdmAppleAPI"], + () => mdmAppleAPI.getAppleAPNInfo(), + { + enabled: isPremiumTier, + staleTime: 5000, + } + ); + + const { + data: mdmAppleBm, + isLoading: isLoadingMdmAppleBm, + error: errorMdmAppleBm, + } = useQuery( + ["mdmAppleBmAPI"], + () => mdmAppleBmAPI.getAppleBMInfo(), + { + enabled: isPremiumTier, + staleTime: 5000, + } + ); + + // MDM TODO: Test manually after backend is merged + const { + data: keys, + error: fetchKeysError, + isFetching: isFetchingKeys, + } = useQuery(["keys"], () => mdmAppleBmAPI.loadKeys(), { + enabled: isPremiumTier, + refetchOnWindowFocus: false, + }); + + const toggleRequestModal = () => { + setShowRequestModal(!showRequestModal); + }; + + const toggleEditTeamModal = () => { + setShowEditTeamModal(!showEditTeamModal); + }; + + const onDownloadKeys = (evt: React.MouseEvent) => { + evt.preventDefault(); + + // MDM TODO: Confirm error flash message + if (isFetchingKeys || fetchKeysError) { + renderFlash( + "error", + "Your MDM business manager keys could not be downloaded. Please try again." + ); + return false; + } + + if (keys) { + // MDM TODO: Validate keys like we validate certificates? + // if (keys && isValidKeys(keys)) { + const filename = "fleet.pem"; + const file = new global.window.File([keys], filename, { + type: "application/x-pem-file", + }); + + FileSaver.saveAs(file); + } else { + renderFlash( + "error", + "Your MDM business manager keys could not be downloaded. Please TODO ACTION." + ); + } + return false; + }; + + const renderMdmAppleSection = () => { + if (errorMdmApple) { + return ; + } + + if (!mdmApple) { + return ( + <> +
+ Connect Fleet to Apple Push Certificates Portal to change settings + and install software on your macOS hosts. +
+
+

+ 1. Request a certificate signing request (CSR) and key for Apple + Push Notification Service (APNs) and a certificate and key for + Simple Certificate Enrollment Protocol (SCEP). +

+ +

2. Go to your email to download your CSR.

+

+ 3.{" "} + +
+ If you don’t have an Apple ID, select Create yours now. +

+

+ 4. In Apple Push Certificates Portal, select{" "} + Create a Certificate, upload your CSR, and download your + APNs certificate. +

+

+ 5. Deploy Fleet with mdm configuration.{" "} + +

+
+ + ); + } + + return ( + <> +
+ To change settings and install software on your macOS hosts, Apple + Inc. requires an Apple Push Notification service (APNs) certificate. +
+
+

Common name (CN)

+

{mdmApple.common_name}

+

Serial number

+

{mdmApple.serial_number}

+

Issuer

+

{mdmApple.issuer}

+

Renew date

+

{readableDate(mdmApple.renew_date)}

+
+ + ); + }; + + const renderMdmAppleBm = () => { + if (errorMdmAppleBm) { + return ; + } + + if (!mdmAppleBm) { + return ( + <> +
+ Connect Fleet to your Apple Business Manager account to + automatically enroll macOS hosts to Fleet when they’re first + unboxed. +
+
+

1. Download your public and private keys.

+ +

+ 2. Sign in to{" "} + +
+ If your organization doesn’t have an account, select{" "} + Enroll now. +

+

+ 3. In Apple Business Manager, upload your public key and download + your server token. +

+

+ 4. Deploy Fleet with mdm configuration.{" "} + +

+
+ + ); + } + + return ( + <> +
+ To use automatically enroll macOS hosts to Fleet when they’re first + unboxed, Apple Inc. requires a server token. +
+
+

+ + Team + +

+

+ {mdmAppleBm.default_team || "No team"}{" "} + +

+

Apple ID

+

{mdmAppleBm.apple_id}

+

Organization name

+

{mdmAppleBm.organization_name}

+

MDM Server URL

+

{mdmAppleBm.mdm_server_url}

+

Renew date

+

{readableDate(mdmAppleBm.renew_date)}

+
+ + ); + }; + + return ( +
+
+

Apple Push Certificates Portal

+ {isLoadingMdmApple ? : renderMdmAppleSection()} +
+ {isPremiumTier && ( +
+

Apple Business Manager

+ {isLoadingMdmAppleBm ? : renderMdmAppleBm()} +
+ )} + {showRequestModal && ( + + )} + {showEditTeamModal && ( + + )} +
+ ); +}; + +export default Mdm; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Mdm/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Mdm/_styles.scss new file mode 100644 index 0000000000..ffaba6093c --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Mdm/_styles.scss @@ -0,0 +1,54 @@ +.mdm-integrations { + display: flex; + flex-direction: column; + width: 65%; + gap: 80px; + + &__section { + margin: 0 0 $pad-large; + + h2 { + padding-bottom: $pad-small; + max-width: 100%; + font-size: $medium; + font-weight: $regular; + color: $core-fleet-black; + border-bottom: solid 1px $ui-fleet-blue-15; + margin: 0 0 $pad-xxlarge; + @media (min-width: $break-990) { + max-width: 65%; + } + } + + h4 { + margin-bottom: 0; + } + + p { + margin: 0; + } + + .mdm-integrations__edit-team-btn { + margin-left: 12px; + + .children-wrapper { + gap: $pad-small; + } + } + + .component__tooltip-wrapper__tip-text { + max-width: initial; + } + } + + &__section-description, + &__section-instructions, + &__section-information { + font-size: $x-small; + color: $core-fleet-black; + width: 100%; + @media (min-width: $break-990) { + width: 60%; + } + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/EditTeamModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/EditTeamModal.tsx new file mode 100644 index 0000000000..66f3bf10bb --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/EditTeamModal.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +interface IEditTeamModal { + onCancel: () => void; + onEdit: () => void; +} + +const baseClass = "edit-team-modal"; + +const EditTeamModal = ({ onCancel, onEdit }: IEditTeamModal): JSX.Element => { + return ( + + <> + Cool beans +
+ + +
+ +
+ ); +}; + +export default EditTeamModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/RequestModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/RequestModal.tsx new file mode 100644 index 0000000000..8e009d825a --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Mdm/components/RequestModal.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +interface IRequestModal { + onCancel: () => void; + onRequest: () => void; +} + +const baseClass = "request-modal"; + +const RequestModal = ({ onCancel, onRequest }: IRequestModal): JSX.Element => { + return ( + + <> + Cool beans +
+ + +
+ +
+ ); +}; + +export default RequestModal; diff --git a/frontend/services/entities/mdm_apple.ts b/frontend/services/entities/mdm_apple.ts new file mode 100644 index 0000000000..18c54e6f33 --- /dev/null +++ b/frontend/services/entities/mdm_apple.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { sendRequest } from "services/mock_service/service/service"; // MDM TODO: Replace when backend is merged +// import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +export default { + getAppleAPNInfo: () => { + const { MDM_APPLE } = endpoints; + const path = MDM_APPLE; + return sendRequest("GET", path); + }, +}; diff --git a/frontend/services/entities/mdm_apple_bm.ts b/frontend/services/entities/mdm_apple_bm.ts new file mode 100644 index 0000000000..16e053375d --- /dev/null +++ b/frontend/services/entities/mdm_apple_bm.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { sendRequest } from "services/mock_service/service/service"; // MDM TODO: Replace when backend is merged +// import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +export default { + getAppleBMInfo: () => { + const { MDM_APPLE_BM } = endpoints; + const path = MDM_APPLE_BM; + return sendRequest("GET", path); + }, + loadKeys: () => { + const { MDM_APPLE_BM_KEYS } = endpoints; + const path = MDM_APPLE_BM_KEYS; + + // MDM TODO: Originally written for certificate_chain for certificate, refactor for keys when backend is merged + return sendRequest("GET", path).then(({ certificate_chain }) => { + let decodedKeys; + try { + decodedKeys = global.window.atob(certificate_chain); + } catch (err) { + return Promise.reject(`Unable to decode keys: ${err}`); + } + if (!decodedKeys) { + return Promise.reject("Missing or undefined keys."); + } + + return Promise.resolve(decodedKeys); + }); + }, +}; diff --git a/frontend/services/mock_service/mocks/config.ts b/frontend/services/mock_service/mocks/config.ts index ab7c5583ec..47d838ebda 100644 --- a/frontend/services/mock_service/mocks/config.ts +++ b/frontend/services/mock_service/mocks/config.ts @@ -22,6 +22,8 @@ const REQUEST_RESPONSE_MAPPINGS: IResponses = { // request query string is hostname, uuid, or mac address; response is host detail excluding any // expensive data operations "targets?query={*}": RESPONSES.hosts, + "mdm/apple": RESPONSES.mdmApple, + "mdm/apple_bm": RESPONSES.mdmAppleBm, }, POST: { // request body is ISelectedTargets diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts index 39ccc7f329..1902928684 100644 --- a/frontend/services/mock_service/mocks/responses.ts +++ b/frontend/services/mock_service/mocks/responses.ts @@ -11,6 +11,24 @@ const count = { targets_missing_in_action: 0, }; +// MDM TODO: Remove mock when backend is merged +const mdmApple = { + common_name: "Mock backend response APSP:04b46ce0-xxxx-xxxx-xxxx-xxxxxxxx", + serial_number: "Mock backend response 123938388712", + issuer: + "Mock backend response Apple Application Integration 2 Certification Authority", + renew_date: "2023-09-30T00:00:00Z", +}; + +// MDM TODO: Remove mock when backend is merged +const mdmAppleBm = { + default_team: "Mock backend response Apples", + apple_id: "Mock backend response rachel@fleetdm.com", + organization_name: "Mock backend response Fleet Device Management", + mdm_server_url: "Mock backend response https://fleet.organization.com/mdm", + renew_date: "2023-09-30T00:00:00Z", +}; + const hosts = { hosts: [ { @@ -368,4 +386,6 @@ export default { count, hosts, labels, + mdmApple, + mdmAppleBm, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index cbb585dcaf..8e32968d47 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -31,6 +31,9 @@ export default { LOGIN: `/${API_VERSION}/fleet/login`, LOGOUT: `/${API_VERSION}/fleet/logout`, MACADMINS: `/${API_VERSION}/fleet/macadmins`, + MDM_APPLE: `/${API_VERSION}/fleet/mdm/apple`, + MDM_APPLE_BM: `/${API_VERSION}/fleet/mdm/apple_bm`, + MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple_bm/keys`, MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, HOST_MDM: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/mdm`, ME: `/${API_VERSION}/fleet/me`, diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index 0569dc667f..3175030665 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -13,6 +13,11 @@ export const isPremiumTier = (config: IConfig): boolean => { return config.license.tier === "premium"; }; +// MDM TODO: Ensure we grabbed the correct config key when backend is merged +export const isMdmEnabled = (config: IConfig): boolean => { + return config.mdm_enabled === true; +}; + export const isGlobalAdmin = (user: IUser): boolean => { return user.global_role === "admin"; }; @@ -106,6 +111,7 @@ export default { isSandboxMode, isFreeTier, isPremiumTier, + isMdmEnabled, isGlobalAdmin, isGlobalMaintainer, isGlobalObserver,