From 525f33e62a609ecae01d4a9c549a91356d13de5c Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWGMorningwood@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:17:12 -0400 Subject: [PATCH] feat: add reusable settings and templates management pages --- .../CippFormPages/CippReusableSettingForm.jsx | 85 ++++++++++ .../CippReusableSettingTemplateForm.jsx | 79 +++++++++ .../CippWizardReusableSettingTemplates.jsx | 153 ++++++++++++++++++ src/layouts/config.js | 10 ++ .../MEM/list-reusable-settings/add.jsx | 75 +++++++++ .../MEM/list-reusable-settings/edit.jsx | 112 +++++++++++++ .../MEM/list-reusable-settings/index.js | 82 ++++++++++ .../MEM/reusable-setting-templates/add.jsx | 54 +++++++ .../MEM/reusable-setting-templates/deploy.js | 43 +++++ .../MEM/reusable-setting-templates/edit.jsx | 96 +++++++++++ .../MEM/reusable-setting-templates/index.js | 143 ++++++++++++++++ 11 files changed, 932 insertions(+) create mode 100644 src/components/CippFormPages/CippReusableSettingForm.jsx create mode 100644 src/components/CippFormPages/CippReusableSettingTemplateForm.jsx create mode 100644 src/components/CippWizard/CippWizardReusableSettingTemplates.jsx create mode 100644 src/pages/endpoint/MEM/list-reusable-settings/add.jsx create mode 100644 src/pages/endpoint/MEM/list-reusable-settings/edit.jsx create mode 100644 src/pages/endpoint/MEM/list-reusable-settings/index.js create mode 100644 src/pages/endpoint/MEM/reusable-setting-templates/add.jsx create mode 100644 src/pages/endpoint/MEM/reusable-setting-templates/deploy.js create mode 100644 src/pages/endpoint/MEM/reusable-setting-templates/edit.jsx create mode 100644 src/pages/endpoint/MEM/reusable-setting-templates/index.js diff --git a/src/components/CippFormPages/CippReusableSettingForm.jsx b/src/components/CippFormPages/CippReusableSettingForm.jsx new file mode 100644 index 000000000000..a8d5e371be64 --- /dev/null +++ b/src/components/CippFormPages/CippReusableSettingForm.jsx @@ -0,0 +1,85 @@ +import { useEffect } from "react"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { useWatch } from "react-hook-form"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; + +const CippReusableSettingForm = (props) => { + const { formControl, isEdit = false } = props; + + const selectedDefinition = useWatch({ control: formControl.control, name: "settingDefinition" }); + + useEffect(() => { + if (selectedDefinition && selectedDefinition.value) { + formControl.setValue("settingDefinitionId", selectedDefinition.value); + } + }, [selectedDefinition, formControl]); + + return ( + + + + + + + + + + + + + + + + + getCippValidator(value, "json"), + }} + helperText="Paste the JSON payload returned by Graph for the setting instance." + /> + + + ); +}; + +export default CippReusableSettingForm; diff --git a/src/components/CippFormPages/CippReusableSettingTemplateForm.jsx b/src/components/CippFormPages/CippReusableSettingTemplateForm.jsx new file mode 100644 index 000000000000..9afce6dad5d3 --- /dev/null +++ b/src/components/CippFormPages/CippReusableSettingTemplateForm.jsx @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { useWatch } from "react-hook-form"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; + +const CippReusableSettingTemplateForm = (props) => { + const { formControl } = props; + + const selectedDefinition = useWatch({ control: formControl.control, name: "settingDefinition" }); + + useEffect(() => { + if (selectedDefinition && selectedDefinition.value) { + formControl.setValue("settingDefinitionId", selectedDefinition.value); + } + }, [selectedDefinition, formControl]); + + return ( + + + + + + + + + + + + + + + + + getCippValidator(value, "json"), + }} + helperText="Provide the JSON payload that should be stored with this template." + /> + + + ); +}; + +export default CippReusableSettingTemplateForm; diff --git a/src/components/CippWizard/CippWizardReusableSettingTemplates.jsx b/src/components/CippWizard/CippWizardReusableSettingTemplates.jsx new file mode 100644 index 000000000000..b5744d2ed8c0 --- /dev/null +++ b/src/components/CippWizard/CippWizardReusableSettingTemplates.jsx @@ -0,0 +1,153 @@ +import { useEffect } from "react"; +import { Stack } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useWatch } from "react-hook-form"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import { getCippValidator } from "/src/utils/get-cipp-validator"; + +export const CippWizardReusableSettingTemplates = (props) => { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; + const templateSelection = useWatch({ control: formControl.control, name: "TemplateList" }); + const definitionSelection = useWatch({ control: formControl.control, name: "settingDefinition" }); + const settingInstanceJson = useWatch({ control: formControl.control, name: "settingInstanceJson" }); + + useEffect(() => { + if (templateSelection?.addedFields) { + const addedFields = templateSelection.addedFields; + const templateDisplayName = + addedFields.displayName || addedFields.DisplayName || templateSelection.label; + const templateDescription = addedFields.description || addedFields.Description || ""; + const templateDefinitionId = + addedFields.settingDefinitionId || addedFields.SettingDefinitionId || ""; + const templateInstance = addedFields.settingInstance || addedFields.SettingInstance; + + formControl.setValue("displayName", templateDisplayName || ""); + formControl.setValue("description", templateDescription || ""); + if (templateDefinitionId) { + formControl.setValue("settingDefinition", { + label: templateDefinitionId, + value: templateDefinitionId, + }); + formControl.setValue("settingDefinitionId", templateDefinitionId); + } + if (templateInstance) { + const rawJson = typeof templateInstance === "string" ? templateInstance : JSON.stringify(templateInstance, null, 2); + formControl.setValue("settingInstanceJson", rawJson); + } + } + }, [templateSelection, formControl]); + + useEffect(() => { + if (definitionSelection?.value) { + formControl.setValue("settingDefinitionId", definitionSelection.value); + } + }, [definitionSelection, formControl]); + + useEffect(() => { + if (!settingInstanceJson) { + formControl.setValue("settingInstance", undefined); + return; + } + + try { + const parsed = JSON.parse(settingInstanceJson); + formControl.setValue("settingInstance", parsed); + } catch (error) { + // Keep previous parsed value, validation will surface through the JSON field validator + } + }, [settingInstanceJson, formControl]); + + return ( + + + + + + option.DisplayName || option.displayName || option.GUID, + valueField: "GUID", + addedField: { + displayName: "DisplayName", + description: "Description", + settingDefinitionId: "SettingDefinitionId", + settingInstance: "SettingInstance", + }, + showRefresh: true, + }} + /> + + + + + + + + + + + + + + + + getCippValidator(value, "json"), + }} + /> + + + + + + ); +}; diff --git a/src/layouts/config.js b/src/layouts/config.js index 115de1795d46..6c7bcd6f4691 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -455,6 +455,16 @@ export const nativeMenuItems = [ path: "/endpoint/MEM/assignment-filter-templates", permissions: ["Endpoint.MEM.*"], }, + { + title: "Reusable Settings", + path: "/endpoint/MEM/list-reusable-settings", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Reusable Setting Templates", + path: "/endpoint/MEM/reusable-setting-templates", + permissions: ["Endpoint.MEM.*"], + }, { title: "Scripts", path: "/endpoint/MEM/list-scripts", diff --git a/src/pages/endpoint/MEM/list-reusable-settings/add.jsx b/src/pages/endpoint/MEM/list-reusable-settings/add.jsx new file mode 100644 index 000000000000..f037eb3e7546 --- /dev/null +++ b/src/pages/endpoint/MEM/list-reusable-settings/add.jsx @@ -0,0 +1,75 @@ +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { Box } from "@mui/material"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippReusableSettingForm from "../../../../components/CippFormPages/CippReusableSettingForm"; + +const Page = () => { + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + settingInstanceJson: "{\n \"@odata.type\": \"#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance\"\n}", + }, + }); + + useEffect(() => { + formControl.setValue("tenantFilter", userSettingsDefaults?.currentTenant || ""); + }, [userSettingsDefaults, formControl]); + + const formatData = (values) => { + const { + settingDefinition, + settingInstanceJson, + settingDefinitionId, + settingId, + ...rest + } = values; + + const payload = { ...rest }; + + const definitionId = settingDefinitionId || settingDefinition?.value; + if (definitionId) { + payload.settingDefinitionId = definitionId; + } + + if (settingId) { + payload.id = settingId; + } + + if (settingInstanceJson) { + try { + payload.settingInstance = JSON.parse(settingInstanceJson); + } catch (error) { + throw new Error("Setting instance JSON is invalid"); + } + } + + return payload; + }; + + return ( + <> + + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/list-reusable-settings/edit.jsx b/src/pages/endpoint/MEM/list-reusable-settings/edit.jsx new file mode 100644 index 000000000000..cc728d8e1a29 --- /dev/null +++ b/src/pages/endpoint/MEM/list-reusable-settings/edit.jsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Box } from "@mui/material"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippReusableSettingForm from "../../../../components/CippFormPages/CippReusableSettingForm"; + +const Page = () => { + const router = useRouter(); + const { settingId } = router.query; + const [ready, setReady] = useState(false); + const { currentTenant } = useSettings(); + + const getInfo = ApiGetCall({ + url: `/api/ListReusableSettings?settingId=${settingId}&tenantFilter=${currentTenant}`, + queryKey: `ListReusableSettings-${settingId}`, + waiting: ready, + }); + + useEffect(() => { + if (settingId) { + setReady(true); + getInfo.refetch(); + } + }, [settingId]); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: currentTenant, + }, + }); + + useEffect(() => { + if (getInfo.isSuccess && getInfo.data) { + const record = Array.isArray(getInfo.data) ? getInfo.data[0] : getInfo.data; + if (record) { + const instanceJson = record.settingInstance + ? JSON.stringify(record.settingInstance, null, 2) + : ""; + formControl.reset({ + tenantFilter: currentTenant, + settingId: record.id, + displayName: record.displayName || "", + description: record.description || "", + settingDefinitionId: record.settingDefinitionId, + settingDefinition: record.settingDefinitionId + ? { label: record.settingDefinitionId, value: record.settingDefinitionId } + : null, + settingInstanceJson: instanceJson, + }); + } + } + }, [getInfo.isSuccess, getInfo.data, currentTenant]); + + const formatData = (values) => { + const { + settingDefinition, + settingInstanceJson, + settingDefinitionId, + settingId, + ...rest + } = values; + + const payload = { ...rest }; + + const definitionId = settingDefinitionId || settingDefinition?.value; + if (definitionId) { + payload.settingDefinitionId = definitionId; + } + + if (settingId) { + payload.settingId = settingId; + payload.id = settingId; + } + + if (settingInstanceJson) { + try { + payload.settingInstance = JSON.parse(settingInstanceJson); + } catch (error) { + throw new Error("Setting instance JSON is invalid"); + } + } + + return payload; + }; + + return ( + <> + + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/list-reusable-settings/index.js b/src/pages/endpoint/MEM/list-reusable-settings/index.js new file mode 100644 index 000000000000..6e0e1cfe3dcd --- /dev/null +++ b/src/pages/endpoint/MEM/list-reusable-settings/index.js @@ -0,0 +1,82 @@ +import { Button } from "@mui/material"; +import { Stack } from "@mui/system"; +import { Add, DeleteOutline, Edit, SaveAlt } from "@mui/icons-material"; +import Link from "next/link"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippJsonView from "../../../../components/CippFormPages/CippJSONView.jsx"; + +const Page = () => { + const { currentTenant } = useSettings(); + const pageTitle = "Reusable Settings"; + + const actions = [ + { + label: "Edit Reusable Setting", + link: "/endpoint/MEM/list-reusable-settings/edit?settingId=[id]", + multiPost: false, + icon: , + color: "success", + }, + { + label: "Create template from setting", + type: "POST", + url: "/api/AddReusableSettingTemplate", + icon: , + data: { + displayName: "displayName", + description: "description", + settingDefinitionId: "settingDefinitionId", + settingInstance: "settingInstance", + }, + confirmText: "Create a reusable setting template based on this setting?", + multiPost: false, + }, + { + label: "Delete Reusable Setting", + type: "POST", + url: "/api/ExecReusableSetting", + icon: , + data: { + Action: "Delete", + ID: "id", + }, + confirmText: "Are you sure you want to delete this reusable setting?", + multiPost: false, + }, + ]; + + const offCanvas = { + children: (data) => ( + + ), + actions, + }; + + return ( + + + + } + apiUrl="/api/ListReusableSettings" + queryKey={`reusable-settings-${currentTenant}`} + actions={actions} + offCanvas={offCanvas} + simpleColumns={["displayName", "description", "settingDefinitionId"]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/reusable-setting-templates/add.jsx b/src/pages/endpoint/MEM/reusable-setting-templates/add.jsx new file mode 100644 index 000000000000..7e6a3062d415 --- /dev/null +++ b/src/pages/endpoint/MEM/reusable-setting-templates/add.jsx @@ -0,0 +1,54 @@ +import { Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippReusableSettingTemplateForm from "../../../../components/CippFormPages/CippReusableSettingTemplateForm"; + +const Page = () => { + const formControl = useForm({ + mode: "onChange", + defaultValues: {}, + }); + + const formatData = (values) => { + const { settingDefinition, settingInstanceJson, settingDefinitionId, ...rest } = values; + const payload = { ...rest }; + + const definitionId = settingDefinitionId || settingDefinition?.value; + if (definitionId) { + payload.settingDefinitionId = definitionId; + } + + if (settingInstanceJson) { + try { + payload.settingInstance = JSON.parse(settingInstanceJson); + } catch (error) { + throw new Error("Setting instance JSON is invalid"); + } + } + + return payload; + }; + + return ( + <> + + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/reusable-setting-templates/deploy.js b/src/pages/endpoint/MEM/reusable-setting-templates/deploy.js new file mode 100644 index 000000000000..41f20608eade --- /dev/null +++ b/src/pages/endpoint/MEM/reusable-setting-templates/deploy.js @@ -0,0 +1,43 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippWizardPage from "/src/components/CippWizard/CippWizardPage.jsx"; +import { CippTenantStep } from "/src/components/CippWizard/CippTenantStep.jsx"; +import { CippWizardConfirmation } from "/src/components/CippWizard/CippWizardConfirmation"; +import { CippWizardReusableSettingTemplates } from "../../../../components/CippWizard/CippWizardReusableSettingTemplates"; + +const Page = () => { + const steps = [ + { + title: "Step 1", + description: "Tenant Selection", + component: CippTenantStep, + componentProps: { + allTenants: false, + type: "multiple", + }, + }, + { + title: "Step 2", + description: "Choose Template", + component: CippWizardReusableSettingTemplates, + }, + { + title: "Step 3", + description: "Confirmation", + component: CippWizardConfirmation, + }, + ]; + + return ( + <> + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/reusable-setting-templates/edit.jsx b/src/pages/endpoint/MEM/reusable-setting-templates/edit.jsx new file mode 100644 index 000000000000..a035a36ccaff --- /dev/null +++ b/src/pages/endpoint/MEM/reusable-setting-templates/edit.jsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Box } from "@mui/material"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippReusableSettingTemplateForm from "../../../../components/CippFormPages/CippReusableSettingTemplateForm"; +import { ApiGetCall } from "../../../../api/ApiCall"; + +const Page = () => { + const router = useRouter(); + const { id } = router.query; + const formControl = useForm({ + mode: "onChange", + defaultValues: {}, + }); + + const templateInfo = ApiGetCall({ + url: `/api/ListReusableSettingTemplates?id=${id}`, + queryKey: `ListReusableSettingTemplates-${id}`, + waiting: Boolean(id), + }); + + useEffect(() => { + if (templateInfo.isSuccess && templateInfo.data) { + const record = Array.isArray(templateInfo.data) ? templateInfo.data[0] : templateInfo.data; + if (record) { + const instanceJson = record.SettingInstance + ? JSON.stringify(record.SettingInstance, null, 2) + : record.settingInstance + ? JSON.stringify(record.settingInstance, null, 2) + : ""; + + const definitionId = record.SettingDefinitionId || record.settingDefinitionId; + + formControl.reset({ + GUID: record.GUID || record.guid || id, + displayName: record.DisplayName || record.displayName || "", + description: record.Description || record.description || "", + settingDefinitionId: definitionId, + settingDefinition: definitionId + ? { + label: definitionId, + value: definitionId, + } + : null, + settingInstanceJson: instanceJson, + }); + } + } + }, [templateInfo.isSuccess, templateInfo.data, id]); + + const formatData = (values) => { + const { settingDefinition, settingInstanceJson, settingDefinitionId, ...rest } = values; + const payload = { ...rest }; + + const definitionId = settingDefinitionId || settingDefinition?.value; + if (definitionId) { + payload.settingDefinitionId = definitionId; + } + + if (settingInstanceJson) { + try { + payload.settingInstance = JSON.parse(settingInstanceJson); + } catch (error) { + throw new Error("Setting instance JSON is invalid"); + } + } + + return payload; + }; + + return ( + <> + + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/reusable-setting-templates/index.js b/src/pages/endpoint/MEM/reusable-setting-templates/index.js new file mode 100644 index 000000000000..9deebc5aadd4 --- /dev/null +++ b/src/pages/endpoint/MEM/reusable-setting-templates/index.js @@ -0,0 +1,143 @@ +import { Button } from "@mui/material"; +import Link from "next/link"; +import { AddBox, Delete, Edit, GitHub, RocketLaunch } from "@mui/icons-material"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard"; +import { getCippTranslation } from "../../../../utils/get-cipp-translation"; +import { getCippFormatting } from "../../../../utils/get-cipp-formatting"; + +const Page = () => { + const pageTitle = "Reusable Setting Templates"; + const integrations = ApiGetCall({ + url: "/api/ListExtensionsConfig", + queryKey: "Integrations", + refetchOnMount: false, + refetchOnReconnect: false, + }); + + const actions = [ + { + label: "Edit Template", + icon: , + link: "/endpoint/MEM/reusable-setting-templates/edit?id=[GUID]", + }, + { + label: "Save to GitHub", + type: "POST", + url: "/api/ExecCommunityRepo", + icon: , + data: { + Action: "UploadTemplate", + GUID: "GUID", + }, + fields: [ + { + label: "Repository", + name: "FullName", + type: "select", + api: { + url: "/api/ListCommunityRepos", + data: { + WriteAccess: true, + }, + queryKey: "CommunityRepos-Write", + dataKey: "Results", + valueField: "FullName", + labelField: "FullName", + }, + multiple: false, + creatable: false, + required: true, + validators: { + required: { value: true, message: "Repository is required" }, + }, + }, + { + label: "Commit Message", + placeholder: "Enter a commit message", + name: "Message", + type: "textField", + multiline: true, + required: true, + rows: 4, + }, + ], + confirmText: "Save this reusable setting template to the selected repository?", + condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled, + }, + { + label: "Delete Template", + type: "POST", + url: "/api/RemoveReusableSettingTemplate", + icon: , + data: { + ID: "GUID", + }, + confirmText: "Delete this reusable setting template?", + multiPost: false, + }, + ]; + + const offCanvas = { + children: (data) => { + const keys = Object.keys(data).filter( + (key) => !key.includes("@odata") && !key.includes("@data") + ); + const properties = []; + keys.forEach((key) => { + if (data[key] && data[key].length !== 0) { + properties.push({ + label: getCippTranslation(key), + value: getCippFormatting(data[key], key), + }); + } + }); + + return ( + + ); + }, + }; + + return ( + + + + + } + offCanvas={offCanvas} + simpleColumns={["DisplayName", "Description", "SettingDefinitionId", "GUID"]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page;