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.
+
+ Download the Fleet profile and select Allow in the
+ pop-up.
+
+
+ Download
+
+ -
+
+ 2.
+
+ Navigate to Settings and select Profile Downloaded.
+
+
+
+
![select profile downloaded in settings]()
+
+
+ -
+
+ 3.
+ Select Install.
+
+
+
![select install]()
+
+
+
+
+
+
+
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())