diff --git a/assets/images/iPadOS-install-profile.png b/assets/images/iPadOS-install-profile.png new file mode 100644 index 0000000000..c398038517 Binary files /dev/null and b/assets/images/iPadOS-install-profile.png differ diff --git a/assets/images/iPadOS-profile-downloaded.png b/assets/images/iPadOS-profile-downloaded.png new file mode 100644 index 0000000000..f21b12615f Binary files /dev/null and b/assets/images/iPadOS-profile-downloaded.png differ diff --git a/assets/images/ios-install-profile.png b/assets/images/ios-install-profile.png new file mode 100644 index 0000000000..8266c55201 Binary files /dev/null and b/assets/images/ios-install-profile.png differ diff --git a/assets/images/ios-profile-downloaded.png b/assets/images/ios-profile-downloaded.png new file mode 100644 index 0000000000..7941a65815 Binary files /dev/null and b/assets/images/ios-profile-downloaded.png differ diff --git a/changes/21559-add-end-user-enrolment-page b/changes/21559-add-end-user-enrolment-page new file mode 100644 index 0000000000..427f1c5beb --- /dev/null +++ b/changes/21559-add-end-user-enrolment-page @@ -0,0 +1 @@ +- add feature for end users to enroll their device into fleet mdm diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 6c363965a8..c8773ba765 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -967,7 +967,7 @@ the way that the Fleet server works. KeyPrefix: "ratelimit::", } - var apiHandler, frontendHandler http.Handler + var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler { frontendHandler = service.PrometheusMetricsHandler( "get_frontend", @@ -985,8 +985,10 @@ the way that the Fleet server works. if setupRequired { apiHandler = service.WithSetup(svc, logger, apiHandler) frontendHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) } else { frontendHandler = service.RedirectSetupToLogin(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.ServeEndUserEnrollOTA(config.Server.URLPrefix, logger) } } @@ -1121,6 +1123,7 @@ the way that the Fleet server works. } apiHandler.ServeHTTP(rw, req) }) + rootMux.Handle("/enroll", endUserEnrollOTAHandler) rootMux.Handle("/", frontendHandler) debugHandler := &debugMux{ diff --git a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx index 658bbd1188..2af31091f6 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx @@ -73,8 +73,9 @@ describe("AddHostsModal", () => { expect(screen.queryByText(/--enable-scripts/i)).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "iOS & iPadOS" })); - expect(screen.queryByText(/Apple Business Manager/i)).toBeInTheDocument(); - expect(screen.queryByText(/Learn more/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Send this to your end users:/i) + ).toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "Advanced" })); const advancedText = screen.getByText(/--type=YOUR_TYPE/i); diff --git a/frontend/components/AddHostsModal/AddHostsModal.tsx b/frontend/components/AddHostsModal/AddHostsModal.tsx index 04a42908ed..1cac771209 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tsx @@ -9,7 +9,6 @@ import Modal from "components/Modal"; import Spinner from "components/Spinner"; import PlatformWrapper from "./PlatformWrapper/PlatformWrapper"; -import DownloadInstallers from "./DownloadInstallers/DownloadInstallers"; const baseClass = "add-hosts-modal"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx new file mode 100644 index 0000000000..40d991e2f8 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx @@ -0,0 +1,46 @@ +import React, { useContext } from "react"; + +import { AppContext } from "context/app"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; + +const generateUrl = (serverUrl: string, enrollSecret: string) => { + return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`; +}; + +const baseClass = "ios-ipados-panel"; + +interface IosIpadosPanelProps { + enrollSecret: string; +} + +const IosIpadosPanel = ({ enrollSecret }: IosIpadosPanelProps) => { + const { config } = useContext(AppContext); + + const helpText = + "When the end user navigates to this URL, the enrollment profile " + + "will download in their browser. End users will have to install the profile " + + "to enroll to Fleet."; + + if (!config) return null; + + const url = generateUrl(config.server_settings.server_url, enrollSecret); + + return ( +
+ +
+ ); +}; + +export default IosIpadosPanel; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss new file mode 100644 index 0000000000..e781e7ca70 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss @@ -0,0 +1,5 @@ +.ios-ipados-panel { + &__spinner { + margin: $pad-xxlarge auto; + } +} diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts new file mode 100644 index 0000000000..4dc628ef88 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts @@ -0,0 +1 @@ +export { default } from "./IosIpadosPanel"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index f10fbead55..a2a8ccb1d1 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -19,6 +19,7 @@ import InfoBanner from "components/InfoBanner/InfoBanner"; import CustomLink from "components/CustomLink/CustomLink"; import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers"; +import IosIpadosPanel from "./IosIpadosPanel"; interface IPlatformSubNav { name: string; @@ -324,7 +325,7 @@ const PlatformWrapper = ({ ); }; - const renderTab = (packageType: string) => { + const renderPanel = (packageType: string) => { const CHROME_OS_INFO = { extensionId: "fleeedmmihkfkeemmipgmhhjemlljidg", installationUrl: "https://chrome.fleetdm.com/updates.xml", @@ -395,19 +396,7 @@ const PlatformWrapper = ({ } if (packageType === "ios-ipados") { - return ( -
-

- Enroll iPhones and iPads by adding them to Fleet in Apple Business - Manager (ABM).{" "} - -

-
- ); + return ; } if (packageType === "advanced") { @@ -590,7 +579,7 @@ const PlatformWrapper = ({ return (
- {renderTab(navItem.type)} + {renderPanel(navItem.type)}
); diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 09ffad2fa4..758ef70ab4 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -43,6 +43,7 @@ class InputField extends Component { PropTypes.object, ]), enableCopy: PropTypes.bool, + copyButtonPosition: PropTypes.oneOfType(["inside", "outside"]), ignore1password: PropTypes.bool, }; @@ -62,6 +63,7 @@ class InputField extends Component { labelTooltipPosition: undefined, helpText: "", enableCopy: false, + copyButtonPosition: "outside", ignore1password: false, }; @@ -97,6 +99,59 @@ class InputField extends Component { return onChange(value); }; + renderCopyButton = () => { + const { value, copyButtonPosition } = this.props; + + const copyValue = (e) => { + e.preventDefault(); + stringToClipboard(value).then(() => { + this.setState({ copied: true }); + setTimeout(() => { + this.setState({ copied: false }); + }, 2000); + }); + }; + + const copyButtonValue = + copyButtonPosition === "outside" ? ( + <> + + Copy + + ) : ( + + ); + + const wrapperClasses = classnames( + `${baseClass}__copy-wrapper`, + copyButtonPosition === "outside" + ? `${baseClass}__copy-wrapper-outside` + : `${baseClass}__copy-wrapper-inside` + ); + + const copiedConfirmationClasses = classnames( + `${baseClass}__copied-confirmation`, + copyButtonPosition === "outside" + ? `${baseClass}__copied-confirmation-outside` + : `${baseClass}__copied-confirmation-inside` + ); + + return ( +
+ + {this.state.copied && ( + Copied! + )} +
+ ); + }; + render() { const { readOnly, @@ -113,6 +168,8 @@ class InputField extends Component { blockAutoComplete, value, ignore1password, + enableCopy, + copyButtonPosition, } = this.props; const { onInputChange } = this; @@ -139,16 +196,6 @@ class InputField extends Component { "labelTooltipPosition", ]); - const copyValue = (e) => { - e.preventDefault(); - stringToClipboard(value).then(() => { - this.setState({ copied: true }); - setTimeout(() => { - this.setState({ copied: false }); - }, 2000); - }); - }; - if (type === "textarea") { return ( - {this.props.enableCopy && ( -
- - {this.state.copied && ( - - Copied! - - )} -
- )} + + {enableCopy && this.renderCopyButton()}
); diff --git a/frontend/components/forms/fields/InputField/InputField.stories.jsx b/frontend/components/forms/fields/InputField/InputField.stories.jsx index 09ccb5a197..7b27aa435c 100644 --- a/frontend/components/forms/fields/InputField/InputField.stories.jsx +++ b/frontend/components/forms/fields/InputField/InputField.stories.jsx @@ -8,3 +8,16 @@ const meta = { export default meta; export const Basic = {}; + +export const WithCopyEnabled = { + args: { + enableCopy: true, + }, +}; + +export const WithCopyEnabledInsideInput = { + args: { + enableCopy: true, + copyButtonPosition: "inside", + }, +}; diff --git a/frontend/components/forms/fields/InputField/_styles.scss b/frontend/components/forms/fields/InputField/_styles.scss index caf6c23713..c70b755175 100644 --- a/frontend/components/forms/fields/InputField/_styles.scss +++ b/frontend/components/forms/fields/InputField/_styles.scss @@ -86,17 +86,32 @@ } } - &__copy-wrapper { + &__copy-wrapper-outside { position: relative; } + &__copy-wrapper-inside { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 12px; + } + + &__input-container.copy-enabled { - display: flex; - align-items: center; - gap: $pad-medium; + &.copy-outside { + display: flex; + align-items: center; + gap: $pad-medium; + } + + &.copy-inside { + position: relative; + } } &__copied-confirmation { + font-size: $x-small; position: absolute; background-color: $ui-light-grey; border: solid 1px $ui-fleet-black-10; @@ -104,6 +119,13 @@ padding: $pad-xxsmall 6px; top: 50%; transform: translateY(-50%); + } + + &__copied-confirmation-inside { + right: 24px; + } + + &__copied-confirmation-outside { left: -90px; } } diff --git a/frontend/styles/byod.css b/frontend/styles/byod.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html new file mode 100644 index 0000000000..6aafd2870d --- /dev/null +++ b/frontend/templates/enroll-ota.html @@ -0,0 +1,180 @@ + + + + + + + Fleet + + + +
+ +
+
+

Enroll your device to Fleet

+

+ Follow the instructions below to download and install the Fleet profile + on your device. +

+
    +
  1. +

    + 1. + + Download the Fleet profile and select Allow in the + pop-up. + +

    + Download +
  2. +
  3. +

    + 2. + + Navigate to Settings and select Profile Downloaded. + +

    +
    + select profile downloaded in settings +
    +
  4. +
  5. +

    + 3. + Select Install. +

    +
    + select install +
    +
  6. +
+
+ + + diff --git a/server/service/frontend.go b/server/service/frontend.go index f5d884ec1f..a2a6058b54 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -1,9 +1,11 @@ package service import ( + "fmt" "html/template" "io" "net/http" + "net/url" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/fleetdm/fleet/v4/server/bindata" @@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl }) } +func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler { + herr := func(w http.ResponseWriter, err string) { + logger.Log("err", err) + http.Error(w, err, http.StatusInternalServerError) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeBrowserSecurityHeaders(w) + + fs := newBinaryFileSystem("/frontend") + file, err := fs.Open("templates/enroll-ota.html") + if err != nil { + herr(w, "load enroll ota template: "+err.Error()) + return + } + + data, err := io.ReadAll(file) + if err != nil { + herr(w, "read bindata file: "+err.Error()) + return + } + + t, err := template.New("enroll-ota").Parse(string(data)) + if err != nil { + herr(w, "create react template: "+err.Error()) + return + } + + enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret")) + if err != nil { + herr(w, "generate enroll ota url: "+err.Error()) + return + } + if err := t.Execute(w, struct { + EnrollURL string + URLPrefix string + }{ + URLPrefix: urlPrefix, + EnrollURL: enrollURL, + }); err != nil { + herr(w, "execute react template: "+err.Error()) + return + } + }) +} + +func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota") + if err != nil { + return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("parsing end user ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + return enrollURL.String(), nil +} + func ServeStaticAssets(path string) http.Handler { return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets"))) } diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go index 32363d6dd8..2710b69e9d 100644 --- a/server/service/frontend_test.go +++ b/server/service/frontend_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "io" "net/http" "net/http/httptest" "os" @@ -40,3 +41,29 @@ func TestServeFrontend(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) } + +func TestServeEndUserEnrollOTA(t *testing.T) { + if !hasBuildTag("full") { + t.Skip("This test requires running with -tags full") + } + logger := log.NewLogfmtLogger(os.Stdout) + h := ServeEndUserEnrollOTA("", logger) + ts := httptest.NewServer(h) + t.Cleanup(func() { + ts.Close() + }) + + // assert html is returned + response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo") + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8") + + // assert it contains the content we expect + defer response.Body.Close() + bodyBytes, err := io.ReadAll(response.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + require.Contains(t, bodyString, "Enroll your device to Fleet") + require.Contains(t, bodyString, "?enroll_secret=foo") +} diff --git a/server/service/handler.go b/server/service/handler.go index 497fd8955f..23b30a3461 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -567,9 +567,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAppleMW.GET("/api/_version_/fleet/mdm/manual_enrollment_profile", getManualEnrollmentProfileEndpoint, getManualEnrollmentProfileRequest{}) mdmAppleMW.GET("/api/_version_/fleet/enrollment_profiles/manual", getManualEnrollmentProfileEndpoint, getManualEnrollmentProfileRequest{}) - // Get OTA profile - mdmAppleMW.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) - // bootstrap-package routes // Deprecated: POST /mdm/bootstrap is now deprecated, replaced by the @@ -890,6 +887,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{}) + // Get OTA profile + neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) + // These endpoint are used by Microsoft devices during MDM device enrollment phase neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM())