From d71fca6d9b6b3ebcd6236b92fb7187875c59f07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sat, 11 Oct 2025 14:45:06 +0200 Subject: [PATCH 01/41] Feat: AllTenants stupport for ListTenantAllowBlockList --- .../email/administration/tenant-allow-block-lists/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/email/administration/tenant-allow-block-lists/index.js b/src/pages/email/administration/tenant-allow-block-lists/index.js index 4770163895fa..b8ac86eba27c 100644 --- a/src/pages/email/administration/tenant-allow-block-lists/index.js +++ b/src/pages/email/administration/tenant-allow-block-lists/index.js @@ -2,8 +2,7 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { Button } from "@mui/material"; import Link from "next/link"; -import TrashIcon from "@heroicons/react/24/outline/TrashIcon"; -import { PlaylistAdd } from "@mui/icons-material"; +import { Delete, PlaylistAdd } from "@mui/icons-material"; const Page = () => { const pageTitle = "Tenant Allow/Block Lists"; @@ -19,7 +18,7 @@ const Page = () => { }, confirmText: "Are you sure you want to delete this entry?", color: "danger", - icon: , + icon: , }, ]; @@ -39,6 +38,7 @@ const Page = () => { apiUrl="/api/ListTenantAllowBlockList" actions={actions} simpleColumns={simpleColumns} + apiDataKey="Results" titleButton={{ label: "Add", href: "/email/administration/tenant-allow-block-list/add", From 8c66ece13d9b2be98d0f1b7565ba4f3e4368253e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sat, 11 Oct 2025 16:09:18 +0200 Subject: [PATCH 02/41] change to use a tenant selector for ease of deploying to many tenants at a time --- .../tenant-allow-block-lists/add.jsx | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/src/pages/email/administration/tenant-allow-block-lists/add.jsx b/src/pages/email/administration/tenant-allow-block-lists/add.jsx index ad8fda8cd4f6..6a2ff1fbb6ea 100644 --- a/src/pages/email/administration/tenant-allow-block-lists/add.jsx +++ b/src/pages/email/administration/tenant-allow-block-lists/add.jsx @@ -5,15 +5,14 @@ import { useForm, useWatch } from "react-hook-form"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippFormPage from "/src/components/CippFormPages/CippFormPage"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { useSettings } from "../../../../hooks/use-settings"; +import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; import { getCippValidator } from "/src/utils/get-cipp-validator"; const AddTenantAllowBlockList = () => { - const tenantDomain = useSettings().currentTenant; - const formControl = useForm({ mode: "onChange", defaultValues: { + tenantID: [], entries: "", notes: "", listType: null, @@ -31,18 +30,19 @@ const AddTenantAllowBlockList = () => { const isListMethodBlock = listMethod?.value === "Block"; const isListTypeFileHash = listType?.value === "FileHash"; const isListTypeSenderUrlOrFileHash = ["Sender", "Url", "FileHash"].includes(listType?.value); - const isNoExpirationCompatible = isListMethodBlock || + const isNoExpirationCompatible = + isListMethodBlock || (listMethod?.value === "Allow" && (listType?.value === "Url" || listType?.value === "IP")); useEffect(() => { if (noExpiration) { formControl.setValue("RemoveAfter", false); } - + if (removeAfter) { formControl.setValue("NoExpiration", false); } - + if (isListMethodBlock) { formControl.setValue("RemoveAfter", false); } @@ -61,62 +61,59 @@ const AddTenantAllowBlockList = () => { } } }, [ - noExpiration, - removeAfter, - isListMethodBlock, - listType, + noExpiration, + removeAfter, + isListMethodBlock, + listType, isListTypeSenderUrlOrFileHash, isListTypeFileHash, isNoExpirationCompatible, - formControl + formControl, ]); const validateEntries = (value) => { if (!value) return true; - - const entries = value.split(/[,;]/).map(e => e.trim()); + + const entries = value.split(/[,;]/).map((e) => e.trim()); const currentListType = listType?.value; - + if (currentListType === "FileHash") { for (const entry of entries) { - if (entry.length !== 64) - return "File hash entries must be exactly 64 characters"; - + if (entry.length !== 64) return "File hash entries must be exactly 64 characters"; + const hashResult = getCippValidator(entry, "sha256"); - if (hashResult !== true) - return hashResult; + if (hashResult !== true) return hashResult; } } else if (currentListType === "IP") { for (const entry of entries) { const ipv6Result = getCippValidator(entry, "ipv6"); const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); - - if (ipv6Result !== true && ipv6CidrResult !== true) + + if (ipv6Result !== true && ipv6CidrResult !== true) return "Invalid IPv6 address format. Use colon-hexadecimal or CIDR notation"; } } else if (currentListType === "Url") { for (const entry of entries) { - if (entry.length > 250) - return "URL entries must be 250 characters or less"; - + if (entry.length > 250) return "URL entries must be 250 characters or less"; + // For entries with wildcards, use the improved wildcard validators - if (entry.includes('*') || entry.includes('~')) { + if (entry.includes("*") || entry.includes("~")) { // Try both wildcard validators const wildcardUrlResult = getCippValidator(entry, "wildcardUrl"); const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); - + if (wildcardUrlResult !== true && wildcardDomainResult !== true) { // If basic pattern check fails too, give a more specific message if (!/^[a-zA-Z0-9\.\-\*\~\/]+$/.test(entry)) { return "Invalid wildcard pattern. Use only letters, numbers, dots, hyphens, slashes, and wildcards (* or ~)"; } - + // If it has basic valid characters but doesn't match our patterns return "Invalid wildcard format. Common formats are *.domain.com or domain.*"; } continue; } - + // For non-wildcard entries, use standard validators const ipv4Result = getCippValidator(entry, "ip"); const ipv4CidrResult = getCippValidator(entry, "ipv4cidr"); @@ -124,38 +121,40 @@ const AddTenantAllowBlockList = () => { const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); const hostnameResult = getCippValidator(entry, "hostname"); const urlResult = getCippValidator(entry, "url"); - + // If none of the validators pass - if (ipv4Result !== true && - ipv4CidrResult !== true && - ipv6Result !== true && - ipv6CidrResult !== true && - hostnameResult !== true && - urlResult !== true) { + if ( + ipv4Result !== true && + ipv4CidrResult !== true && + ipv6Result !== true && + ipv6CidrResult !== true && + hostnameResult !== true && + urlResult !== true + ) { return "Invalid URL format. Enter hostnames, IPv4, or IPv6 addresses"; } } } else if (currentListType === "Sender") { for (const entry of entries) { // Check for wildcards first - if (entry.includes('*') || entry.includes('~')) { + if (entry.includes("*") || entry.includes("~")) { const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); - + if (wildcardDomainResult !== true) { return "Invalid sender wildcard pattern. Common format is *.domain.com"; } continue; } - + // For non-wildcard entries, use senderEntry validator const senderResult = getCippValidator(entry, "senderEntry"); - + if (senderResult !== true) { return senderResult; } } } - + return true; }; @@ -168,17 +167,29 @@ const AddTenantAllowBlockList = () => { postUrl="/api/AddTenantAllowBlockList" customDataformatter={(values) => { return { - tenantID: tenantDomain, + tenantID: values.tenantID, entries: values.entries, listType: values.listType?.value, notes: values.notes, listMethod: values.listMethod?.value, NoExpiration: values.NoExpiration, - RemoveAfter: values.RemoveAfter + RemoveAfter: values.RemoveAfter, }; }} > + + + + {/* Entries */} { label="Entries" name="entries" formControl={formControl} - validators={{ + validators={{ required: "Entries field is required", - validate: validateEntries + validate: validateEntries, }} helperText={ listType?.value === "FileHash" @@ -266,11 +277,12 @@ const AddTenantAllowBlockList = () => { : "Available only for Block entries or specific Allow entries (URL/IP)" } disabled={ - removeAfter || - !(isListMethodBlock || - (listMethod?.value === "Allow" && - (listType?.value === "Url" || - listType?.value === "IP"))) + removeAfter || + !( + isListMethodBlock || + (listMethod?.value === "Allow" && + (listType?.value === "Url" || listType?.value === "IP")) + ) } /> @@ -284,8 +296,8 @@ const AddTenantAllowBlockList = () => { formControl={formControl} helperText="If checked, allow entries will be removed after 45 days of last use" disabled={ - noExpiration || - listMethod?.value !== "Allow" || + noExpiration || + listMethod?.value !== "Allow" || !["Sender", "FileHash", "Url"].includes(listType?.value) } /> From 39e93c052c18bcfc45d8416117680289bf1de49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sat, 11 Oct 2025 17:02:50 +0200 Subject: [PATCH 03/41] Feat: Refactor add page into drawer for adding tenant allow/block list entries --- .../CippAddTenantAllowBlockListDrawer.jsx | 378 ++++++++++++++++++ .../tenant-allow-block-lists/add.jsx | 312 --------------- .../tenant-allow-block-lists/index.js | 22 +- 3 files changed, 382 insertions(+), 330 deletions(-) create mode 100644 src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx delete mode 100644 src/pages/email/administration/tenant-allow-block-lists/add.jsx diff --git a/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx new file mode 100644 index 000000000000..23fbe252ae55 --- /dev/null +++ b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx @@ -0,0 +1,378 @@ +import { useEffect, useState } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState, useWatch } from "react-hook-form"; +import { PlaylistAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import { getCippValidator } from "../../utils/get-cipp-validator"; + +const defaultValues = { + tenantID: [], + entries: "", + notes: "", + listType: null, + listMethod: null, + NoExpiration: false, + RemoveAfter: false, +}; + +export const CippAddTenantAllowBlockListDrawer = ({ + buttonText = "Add Entry", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const noExpiration = useWatch({ control: formControl.control, name: "NoExpiration" }); + const removeAfter = useWatch({ control: formControl.control, name: "RemoveAfter" }); + const listMethod = useWatch({ control: formControl.control, name: "listMethod" }); + const listType = useWatch({ control: formControl.control, name: "listType" }); + + const isListMethodBlock = listMethod?.value === "Block"; + const isListTypeFileHash = listType?.value === "FileHash"; + const isListTypeSenderUrlOrFileHash = ["Sender", "Url", "FileHash"].includes(listType?.value); + const isNoExpirationCompatible = + isListMethodBlock || (listMethod?.value === "Allow" && ["Url", "IP"].includes(listType?.value)); + + const addEntry = ApiPostCall({}); + + useEffect(() => { + if (noExpiration && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (removeAfter && formControl.getValues("NoExpiration")) { + formControl.setValue("NoExpiration", false, { shouldValidate: true }); + } + + if (isListMethodBlock && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (listType && !isListTypeSenderUrlOrFileHash && formControl.getValues("RemoveAfter")) { + formControl.setValue("RemoveAfter", false, { shouldValidate: true }); + } + + if (isListTypeFileHash && listMethod?.value !== "Block") { + formControl.setValue( + "listMethod", + { label: "Block", value: "Block" }, + { shouldValidate: true } + ); + } + + if ((listMethod || listType) && noExpiration && !isNoExpirationCompatible) { + formControl.setValue("NoExpiration", false, { shouldValidate: true }); + } + }, [ + noExpiration, + removeAfter, + isListMethodBlock, + listType, + isListTypeSenderUrlOrFileHash, + isListTypeFileHash, + isNoExpirationCompatible, + listMethod, + formControl, + ]); + + useEffect(() => { + if (addEntry.isSuccess) { + const currentTenants = formControl.getValues("tenantID"); + formControl.reset({ + ...defaultValues, + tenantID: currentTenants, + }); + } + }, [addEntry.isSuccess, formControl]); + + const validateEntries = (value) => { + if (!value) return true; + + const entries = value + .split(/[,;]/) + .map((entry) => entry.trim()) + .filter(Boolean); + const currentListType = listType?.value; + + if (currentListType === "FileHash") { + for (const entry of entries) { + if (entry.length !== 64) return "File hash entries must be exactly 64 characters"; + + const hashResult = getCippValidator(entry, "sha256"); + if (hashResult !== true) return hashResult; + } + return true; + } + + if (currentListType === "IP") { + for (const entry of entries) { + const ipv6Result = getCippValidator(entry, "ipv6"); + const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); + + if (ipv6Result !== true && ipv6CidrResult !== true) { + return "Invalid IPv6 address format. Use colon-hexadecimal or CIDR notation"; + } + } + return true; + } + + if (currentListType === "Url") { + for (const entry of entries) { + if (entry.length > 250) { + return "URL entries must be 250 characters or less"; + } + + if (entry.includes("*") || entry.includes("~")) { + const wildcardUrlResult = getCippValidator(entry, "wildcardUrl"); + const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); + + if (wildcardUrlResult === true || wildcardDomainResult === true) { + continue; + } + + if (!/^[a-zA-Z0-9.\-*~\/]+$/.test(entry)) { + return "Invalid wildcard pattern. Use only letters, numbers, dots, hyphens, slashes, and wildcards (* or ~)"; + } + + return "Invalid wildcard format. Common formats are *.domain.com or domain.*"; + } + + const ipv4Result = getCippValidator(entry, "ip"); + const ipv4CidrResult = getCippValidator(entry, "ipv4cidr"); + const ipv6Result = getCippValidator(entry, "ipv6"); + const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); + const hostnameResult = getCippValidator(entry, "hostname"); + const urlResult = getCippValidator(entry, "url"); + + if ( + ipv4Result !== true && + ipv4CidrResult !== true && + ipv6Result !== true && + ipv6CidrResult !== true && + hostnameResult !== true && + urlResult !== true + ) { + return "Invalid URL format. Enter hostnames, IPv4, or IPv6 addresses"; + } + } + return true; + } + + if (currentListType === "Sender") { + for (const entry of entries) { + if (entry.includes("*") || entry.includes("~")) { + const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); + + if (wildcardDomainResult !== true) { + return "Invalid sender wildcard pattern. Common format is *.domain.com"; + } + continue; + } + + const senderResult = getCippValidator(entry, "senderEntry"); + if (senderResult !== true) { + return senderResult; + } + } + return true; + } + + return true; + }; + + const handleSubmit = formControl.handleSubmit((values) => { + const payload = { + tenantID: values.tenantID, + entries: values.entries, + listType: values.listType?.value, + notes: values.notes, + listMethod: values.listMethod?.value, + NoExpiration: values.NoExpiration, + RemoveAfter: values.RemoveAfter, + }; + + addEntry.mutate({ + url: "/api/AddTenantAllowBlockList", + data: payload, + }); + }); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(defaultValues); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CippAddTenantAllowBlockListDrawer; diff --git a/src/pages/email/administration/tenant-allow-block-lists/add.jsx b/src/pages/email/administration/tenant-allow-block-lists/add.jsx deleted file mode 100644 index 6a2ff1fbb6ea..000000000000 --- a/src/pages/email/administration/tenant-allow-block-lists/add.jsx +++ /dev/null @@ -1,312 +0,0 @@ -import { useEffect } from "react"; -import "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; -import { getCippValidator } from "/src/utils/get-cipp-validator"; - -const AddTenantAllowBlockList = () => { - const formControl = useForm({ - mode: "onChange", - defaultValues: { - tenantID: [], - entries: "", - notes: "", - listType: null, - listMethod: null, - NoExpiration: false, - RemoveAfter: false, - }, - }); - - const noExpiration = useWatch({ control: formControl.control, name: "NoExpiration" }); - const removeAfter = useWatch({ control: formControl.control, name: "RemoveAfter" }); - const listMethod = useWatch({ control: formControl.control, name: "listMethod" }); - const listType = useWatch({ control: formControl.control, name: "listType" }); - - const isListMethodBlock = listMethod?.value === "Block"; - const isListTypeFileHash = listType?.value === "FileHash"; - const isListTypeSenderUrlOrFileHash = ["Sender", "Url", "FileHash"].includes(listType?.value); - const isNoExpirationCompatible = - isListMethodBlock || - (listMethod?.value === "Allow" && (listType?.value === "Url" || listType?.value === "IP")); - - useEffect(() => { - if (noExpiration) { - formControl.setValue("RemoveAfter", false); - } - - if (removeAfter) { - formControl.setValue("NoExpiration", false); - } - - if (isListMethodBlock) { - formControl.setValue("RemoveAfter", false); - } - - if (listType && !isListTypeSenderUrlOrFileHash) { - formControl.setValue("RemoveAfter", false); - } - - if (isListTypeFileHash) { - formControl.setValue("listMethod", { label: "Block", value: "Block" }); - } - - if (listMethod || listType) { - if (!isNoExpirationCompatible && noExpiration) { - formControl.setValue("NoExpiration", false); - } - } - }, [ - noExpiration, - removeAfter, - isListMethodBlock, - listType, - isListTypeSenderUrlOrFileHash, - isListTypeFileHash, - isNoExpirationCompatible, - formControl, - ]); - - const validateEntries = (value) => { - if (!value) return true; - - const entries = value.split(/[,;]/).map((e) => e.trim()); - const currentListType = listType?.value; - - if (currentListType === "FileHash") { - for (const entry of entries) { - if (entry.length !== 64) return "File hash entries must be exactly 64 characters"; - - const hashResult = getCippValidator(entry, "sha256"); - if (hashResult !== true) return hashResult; - } - } else if (currentListType === "IP") { - for (const entry of entries) { - const ipv6Result = getCippValidator(entry, "ipv6"); - const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); - - if (ipv6Result !== true && ipv6CidrResult !== true) - return "Invalid IPv6 address format. Use colon-hexadecimal or CIDR notation"; - } - } else if (currentListType === "Url") { - for (const entry of entries) { - if (entry.length > 250) return "URL entries must be 250 characters or less"; - - // For entries with wildcards, use the improved wildcard validators - if (entry.includes("*") || entry.includes("~")) { - // Try both wildcard validators - const wildcardUrlResult = getCippValidator(entry, "wildcardUrl"); - const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); - - if (wildcardUrlResult !== true && wildcardDomainResult !== true) { - // If basic pattern check fails too, give a more specific message - if (!/^[a-zA-Z0-9\.\-\*\~\/]+$/.test(entry)) { - return "Invalid wildcard pattern. Use only letters, numbers, dots, hyphens, slashes, and wildcards (* or ~)"; - } - - // If it has basic valid characters but doesn't match our patterns - return "Invalid wildcard format. Common formats are *.domain.com or domain.*"; - } - continue; - } - - // For non-wildcard entries, use standard validators - const ipv4Result = getCippValidator(entry, "ip"); - const ipv4CidrResult = getCippValidator(entry, "ipv4cidr"); - const ipv6Result = getCippValidator(entry, "ipv6"); - const ipv6CidrResult = getCippValidator(entry, "ipv6cidr"); - const hostnameResult = getCippValidator(entry, "hostname"); - const urlResult = getCippValidator(entry, "url"); - - // If none of the validators pass - if ( - ipv4Result !== true && - ipv4CidrResult !== true && - ipv6Result !== true && - ipv6CidrResult !== true && - hostnameResult !== true && - urlResult !== true - ) { - return "Invalid URL format. Enter hostnames, IPv4, or IPv6 addresses"; - } - } - } else if (currentListType === "Sender") { - for (const entry of entries) { - // Check for wildcards first - if (entry.includes("*") || entry.includes("~")) { - const wildcardDomainResult = getCippValidator(entry, "wildcardDomain"); - - if (wildcardDomainResult !== true) { - return "Invalid sender wildcard pattern. Common format is *.domain.com"; - } - continue; - } - - // For non-wildcard entries, use senderEntry validator - const senderResult = getCippValidator(entry, "senderEntry"); - - if (senderResult !== true) { - return senderResult; - } - } - } - - return true; - }; - - return ( - { - return { - tenantID: values.tenantID, - entries: values.entries, - listType: values.listType?.value, - notes: values.notes, - listMethod: values.listMethod?.value, - NoExpiration: values.NoExpiration, - RemoveAfter: values.RemoveAfter, - }; - }} - > - - - - - - {/* Entries */} - - - - {/* Notes & List Type */} - - - - - - - - {/* List Method */} - - - - - {/* No Expiration */} - - - - - {/* Remove After */} - - - - - - ); -}; - -AddTenantAllowBlockList.getLayout = (page) => {page}; - -export default AddTenantAllowBlockList; diff --git a/src/pages/email/administration/tenant-allow-block-lists/index.js b/src/pages/email/administration/tenant-allow-block-lists/index.js index b8ac86eba27c..628eacd1be83 100644 --- a/src/pages/email/administration/tenant-allow-block-lists/index.js +++ b/src/pages/email/administration/tenant-allow-block-lists/index.js @@ -1,11 +1,11 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button } from "@mui/material"; -import Link from "next/link"; -import { Delete, PlaylistAdd } from "@mui/icons-material"; +import { Delete } from "@mui/icons-material"; +import { CippAddTenantAllowBlockListDrawer } from "/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx"; const Page = () => { const pageTitle = "Tenant Allow/Block Lists"; + const cardButtonPermissions = ["Exchange.SpamFilter.ReadWrite"]; const actions = [ { @@ -39,21 +39,7 @@ const Page = () => { actions={actions} simpleColumns={simpleColumns} apiDataKey="Results" - titleButton={{ - label: "Add", - href: "/email/administration/tenant-allow-block-list/add", - }} - cardButton={ - <> - - - } + cardButton={} /> ); }; From f1ee80449026014d7eab51fd94288b027aa9261b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sun, 12 Oct 2025 13:35:34 +0200 Subject: [PATCH 04/41] Feat: Standard to control recovering BitLocker keys for owned devices - Fixes #4806 --- src/data/standards.json | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index d146bf5b9a7a..c29a094ed366 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -912,6 +912,39 @@ "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", "recommendedBy": ["CIS", "CIPP"] }, + { + "name": "standards.BitLockerKeysForOwnedDevice", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", + "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", + "executiveText": "Gives administrators centralized control over BitLocker recovery secrets—restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select state", + "name": "standards.BitLockerKeysForOwnedDevice.state", + "options": [ + { + "label": "Restrict", + "value": "restrict" + }, + { + "label": "Allow", + "value": "allow" + } + ] + } + ], + "label": "Control BitLocker key recovery for owned devices", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-10-12", + "powershellEquivalent": "Update-MgBetaPolicyAuthorizationPolicy", + "recommendedBy": [] + }, { "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", From 87986ae697ba74095ed5c99e5f25c4af1f1f6c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sun, 12 Oct 2025 16:46:33 +0200 Subject: [PATCH 05/41] Feat: Release notes popup - Fixes #4780 Expand functionality for big screens add "view release notes" button to top right account-popover menu refactor to use ExecGitHubAction refactor: replace axios with ApiGetCall for fetching release notes --- package.json | 4 +- src/components/PrivateRoute.js | 12 +- src/components/ReleaseNotesDialog.js | 262 +++++++++++ src/contexts/release-notes-context.js | 30 ++ src/layouts/account-popover.js | 16 + src/pages/_app.js | 5 +- yarn.lock | 622 ++++++++++++++++++++++++++ 7 files changed, 945 insertions(+), 6 deletions(-) create mode 100644 src/components/ReleaseNotesDialog.js create mode 100644 src/contexts/release-notes-context.js diff --git a/package.json b/package.json index c408d29c4b78..1444ad6102ca 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,8 @@ "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^3.0.1", "react-media-hook": "^0.5.0", "react-papaparse": "^4.4.0", "react-quill": "^2.0.0", @@ -112,4 +114,4 @@ "eslint": "9.35.0", "eslint-config-next": "15.5.2" } -} \ No newline at end of file +} diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 34db29fd7a1c..5b067cf4e7c3 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -69,10 +69,14 @@ export const PrivateRoute = ({ children, routeType }) => { const userRoles = roles?.filter((role) => !blockedRoles.includes(role)) ?? []; const isAuthenticated = userRoles.length > 0 && !apiRoles?.error; const isAdmin = roles?.includes("admin") || roles?.includes("superadmin"); - if (routeType === "admin") { - return !isAdmin ? : children; - } else { - return !isAuthenticated ? : children; + if (routeType === "admin" && !isAdmin) { + return ; } + + if (!isAuthenticated) { + return ; + } + + return children; } }; diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js new file mode 100644 index 000000000000..c395279d8fe8 --- /dev/null +++ b/src/components/ReleaseNotesDialog.js @@ -0,0 +1,262 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + Stack, + Typography, +} from "@mui/material"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import packageInfo from "../../public/version.json"; +import { ApiGetCall } from "../api/ApiCall"; + +const RELEASE_COOKIE_KEY = "cipp_release_notice"; +const RELEASE_OWNER = "KelvinTegelaar"; +const RELEASE_REPO = "CIPP"; + +const secureFlag = () => { + if (typeof window === "undefined") { + return ""; + } + + return window.location.protocol === "https:" ? " Secure" : ""; +}; + +const getCookie = (name) => { + if (typeof document === "undefined") { + return null; + } + + const cookiePrefix = `${name}=`; + const cookies = document.cookie.split("; "); + + for (const cookie of cookies) { + if (cookie.startsWith(cookiePrefix)) { + return decodeURIComponent(cookie.slice(cookiePrefix.length)); + } + } + + return null; +}; + +const setCookie = (name, value, days = 365) => { + if (typeof document === "undefined") { + return; + } + + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); + document.cookie = `${name}=${encodeURIComponent( + value + )}; expires=${expires}; path=/; SameSite=Lax;${secureFlag()}`; +}; + +const buildReleaseMetadata = (version) => { + const [major = "0", minor = "0", patch = "0"] = String(version).split("."); + const currentTag = `v${major}.${minor}.${patch}`; + const baseTag = `v${major}.${minor}.0`; + const tagToUse = patch === "0" ? currentTag : baseTag; + + return { + currentTag, + releaseTag: tagToUse, + releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${tagToUse}`, + }; +}; + +const formatReleaseBody = (body) => { + if (!body) { + return ""; + } + + return body.replace(/(^|[^\w/])@([a-zA-Z0-9-]+)/g, (match, prefix, username) => { + return `${prefix}[@${username}](https://github.com/${username})`; + }); +}; + +export const ReleaseNotesDialog = forwardRef((_props, ref) => { + const [isEligible, setIsEligible] = useState(false); + const [open, setOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [manualOpenRequested, setManualOpenRequested] = useState(false); + const hasOpenedRef = useRef(false); + + const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []); + + useEffect(() => { + hasOpenedRef.current = false; + }, [releaseMeta.releaseTag]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const storedValue = getCookie(RELEASE_COOKIE_KEY); + + if (storedValue !== releaseMeta.releaseTag) { + setIsEligible(true); + } + }, [releaseMeta.releaseTag]); + + const releaseQuery = ApiGetCall({ + url: "/api/ListGitHubReleaseNotes", + queryKey: ["list-github-release-notes", releaseMeta.releaseTag], + data: { + Owner: RELEASE_OWNER, + Repository: RELEASE_REPO, + Version: releaseMeta.releaseTag, + }, + }); + + useImperativeHandle(ref, () => ({ + open: () => { + setManualOpenRequested(true); + setOpen(true); + }, + })); + + const handleDismissUntilNextRelease = () => { + const tagToStore = releaseQuery.data?.releaseTag ?? releaseMeta.releaseTag; + setCookie(RELEASE_COOKIE_KEY, tagToStore); + setOpen(false); + setIsExpanded(false); + setManualOpenRequested(false); + setIsEligible(false); + }; + + const handleRemindLater = () => { + setOpen(false); + setIsExpanded(false); + setManualOpenRequested(false); + }; + + const toggleExpanded = () => { + setIsExpanded((prev) => !prev); + }; + + const releaseName = releaseQuery.data?.name || `CIPP ${releaseMeta.currentTag}`; + const releaseBody = releaseQuery.data?.body || ""; + const releaseUrl = releaseQuery.data?.url ?? releaseMeta.releaseUrl; + const formattedReleaseBody = useMemo(() => formatReleaseBody(releaseBody), [releaseBody]); + + useEffect(() => { + if (!isEligible || hasOpenedRef.current) { + return; + } + + if (releaseQuery.data || releaseQuery.error) { + setOpen(true); + hasOpenedRef.current = true; + } + }, [isEligible, releaseQuery.data, releaseQuery.error]); + + return ( + + + {`What's new in CIPP ${releaseMeta.currentTag}`} + + + + + {releaseName} + + The latest release notes are provided below. You can always read them on GitHub if you + prefer. + + {releaseQuery.isLoading ? ( + + + + ) : releaseQuery.error ? ( + + We couldn't load the release notes right now. You can view them on GitHub instead. + {releaseQuery.error?.message ? ` (${releaseQuery.error.message})` : ""} + + ) : ( + + ( + + ), + img: ({ node, ...props }) => ( + + ), + }} + rehypePlugins={[rehypeRaw]} + remarkPlugins={[remarkGfm]} + > + {formattedReleaseBody} + + + )} + + + + + View release notes on GitHub + + + + + + + + ); +}); + +ReleaseNotesDialog.displayName = "ReleaseNotesDialog"; diff --git a/src/contexts/release-notes-context.js b/src/contexts/release-notes-context.js new file mode 100644 index 000000000000..54f29623522e --- /dev/null +++ b/src/contexts/release-notes-context.js @@ -0,0 +1,30 @@ +import { createContext, useCallback, useContext, useMemo, useRef } from "react"; +import PropTypes from "prop-types"; +import { ReleaseNotesDialog } from "../components/ReleaseNotesDialog"; + +const ReleaseNotesContext = createContext({ + openReleaseNotes: () => {}, +}); + +export const ReleaseNotesProvider = ({ children }) => { + const dialogRef = useRef(null); + + const openReleaseNotes = useCallback(() => { + dialogRef.current?.open(); + }, []); + + const value = useMemo(() => ({ openReleaseNotes }), [openReleaseNotes]); + + return ( + + {children} + + + ); +}; + +ReleaseNotesProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const useReleaseNotes = () => useContext(ReleaseNotesContext); diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index cc0da2049060..e4dc4c12dbcd 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -26,6 +26,8 @@ import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; import { CogIcon } from "@heroicons/react/24/outline"; import { useQueryClient } from "@tanstack/react-query"; +import DocumentTextIcon from "@heroicons/react/24/outline/DocumentTextIcon"; +import { useReleaseNotes } from "../contexts/release-notes-context"; export const AccountPopover = (props) => { const { @@ -39,6 +41,7 @@ export const AccountPopover = (props) => { const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const popover = usePopover(); const queryClient = useQueryClient(); + const { openReleaseNotes } = useReleaseNotes(); const orgData = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", @@ -152,6 +155,19 @@ export const AccountPopover = (props) => { + { + popover.handleClose(); + openReleaseNotes(); + }} + > + + + + + + + diff --git a/src/pages/_app.js b/src/pages/_app.js index 3623d863d18a..b7af5f4a3bcc 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -4,6 +4,7 @@ import { Provider as ReduxProvider } from "react-redux"; import { CacheProvider } from "@emotion/react"; import { ThemeProvider } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; +import { ReleaseNotesProvider } from "../contexts/release-notes-context"; import { SettingsConsumer, SettingsProvider } from "../contexts/settings-context"; import { RTL } from "../components/rtl"; import { store } from "../store"; @@ -298,7 +299,9 @@ const App = (props) => { - {getLayout()} + + {getLayout()} + Date: Sun, 12 Oct 2025 21:41:44 +0200 Subject: [PATCH 06/41] make button cause john is UX man refactor: enhance ReleaseNotesDialog with release selection and loading states save some api calls handle gfm plugin exploding refactor: remove unused 'Mode' property from release data refactor: reorganize MarkdownErrorBoundary class and improve release catalog memoization --- src/components/ReleaseNotesDialog.js | 301 +++++++++++++++++++++++---- 1 file changed, 256 insertions(+), 45 deletions(-) diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index c395279d8fe8..768fb77cf60a 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -1,4 +1,13 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { + Component, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { Box, Button, @@ -13,9 +22,13 @@ import { } from "@mui/material"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; import rehypeRaw from "rehype-raw"; +import { unified } from "unified"; import packageInfo from "../../public/version.json"; import { ApiGetCall } from "../api/ApiCall"; +import { GitHub } from "@mui/icons-material"; +import { CippAutoComplete } from "./CippComponents/CippAutocomplete"; const RELEASE_COOKIE_KEY = "cipp_release_notice"; const RELEASE_OWNER = "KelvinTegelaar"; @@ -80,19 +93,49 @@ const formatReleaseBody = (body) => { }); }; +class MarkdownErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.error("Failed to render release notes", error); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + + return this.props.children; + } +} + export const ReleaseNotesDialog = forwardRef((_props, ref) => { + const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []); const [isEligible, setIsEligible] = useState(false); const [open, setOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [manualOpenRequested, setManualOpenRequested] = useState(false); + const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.releaseTag); const hasOpenedRef = useRef(false); - const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []); - useEffect(() => { hasOpenedRef.current = false; }, [releaseMeta.releaseTag]); + useEffect(() => { + setSelectedReleaseTag(releaseMeta.releaseTag); + }, [releaseMeta.releaseTag]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -105,16 +148,98 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { } }, [releaseMeta.releaseTag]); - const releaseQuery = ApiGetCall({ + const shouldFetchReleaseList = isEligible || manualOpenRequested || open; + + const releaseListQuery = ApiGetCall({ url: "/api/ListGitHubReleaseNotes", - queryKey: ["list-github-release-notes", releaseMeta.releaseTag], + queryKey: "list-github-release-options", data: { Owner: RELEASE_OWNER, Repository: RELEASE_REPO, - Version: releaseMeta.releaseTag, }, + waiting: shouldFetchReleaseList, + staleTime: 300000, }); + const isReleaseListLoading = releaseListQuery.isLoading || releaseListQuery.isFetching; + + const releaseCatalog = useMemo(() => { + return Array.isArray(releaseListQuery.data) ? releaseListQuery.data : []; + }, [releaseListQuery.data]); + + useEffect(() => { + if (!releaseCatalog.length) { + return; + } + + if (!selectedReleaseTag) { + setSelectedReleaseTag(releaseCatalog[0].releaseTag); + return; + } + + const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag); + + if (!hasSelected) { + const fallbackRelease = + releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + releaseCatalog[0]; + if (fallbackRelease) { + setSelectedReleaseTag(fallbackRelease.releaseTag); + } + } + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]); + + const releaseOptions = useMemo(() => { + const mapped = releaseCatalog.map((release) => { + const tag = release.releaseTag ?? release.tagName; + const label = release.name ? `${release.name} (${tag})` : tag; + return { + label, + value: tag, + addedFields: { + htmlUrl: release.htmlUrl, + publishedAt: release.publishedAt, + }, + }; + }); + + if (selectedReleaseTag && !mapped.some((option) => option.value === selectedReleaseTag)) { + mapped.push({ + label: selectedReleaseTag, + value: selectedReleaseTag, + addedFields: { + htmlUrl: releaseMeta.releaseUrl, + publishedAt: null, + }, + }); + } + + return mapped; + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseUrl]); + + const selectedReleaseValue = useMemo(() => { + if (!selectedReleaseTag) { + return null; + } + + return ( + releaseOptions.find((option) => option.value === selectedReleaseTag) || { + label: selectedReleaseTag, + value: selectedReleaseTag, + } + ); + }, [releaseOptions, selectedReleaseTag]); + + const handleReleaseChange = useCallback( + (newValue) => { + const nextValue = Array.isArray(newValue) ? newValue[0] : newValue; + if (nextValue?.value && nextValue.value !== selectedReleaseTag) { + setSelectedReleaseTag(nextValue.value); + } + }, + [selectedReleaseTag] + ); + useImperativeHandle(ref, () => ({ open: () => { setManualOpenRequested(true); @@ -122,8 +247,21 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, })); + const selectedReleaseData = useMemo(() => { + if (!selectedReleaseTag) { + return null; + } + + return ( + releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + null + ); + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseTag]); + const handleDismissUntilNextRelease = () => { - const tagToStore = releaseQuery.data?.releaseTag ?? releaseMeta.releaseTag; + const tagToStore = + selectedReleaseData?.releaseTag ?? selectedReleaseTag ?? releaseMeta.releaseTag; setCookie(RELEASE_COOKIE_KEY, tagToStore); setOpen(false); setIsExpanded(false); @@ -141,21 +279,40 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { setIsExpanded((prev) => !prev); }; - const releaseName = releaseQuery.data?.name || `CIPP ${releaseMeta.currentTag}`; - const releaseBody = releaseQuery.data?.body || ""; - const releaseUrl = releaseQuery.data?.url ?? releaseMeta.releaseUrl; + const requestedVersionLabel = + selectedReleaseData?.releaseTag ?? selectedReleaseTag ?? releaseMeta.currentTag; + const releaseName = + selectedReleaseData?.name || selectedReleaseValue?.label || `CIPP ${releaseMeta.currentTag}`; + const releaseHeading = releaseName || requestedVersionLabel; + const releaseBody = typeof selectedReleaseData?.body === "string" ? selectedReleaseData.body : ""; + const releaseUrl = + selectedReleaseData?.htmlUrl ?? + selectedReleaseValue?.addedFields?.htmlUrl ?? + releaseMeta.releaseUrl; const formattedReleaseBody = useMemo(() => formatReleaseBody(releaseBody), [releaseBody]); + const gfmSupport = useMemo(() => { + if (!formattedReleaseBody) { + return { plugins: [remarkGfm], error: null }; + } + + try { + unified().use(remarkParse).use(remarkGfm).parse(formattedReleaseBody); + return { plugins: [remarkGfm], error: null }; + } catch (err) { + return { plugins: [], error: err }; + } + }, [formattedReleaseBody]); useEffect(() => { if (!isEligible || hasOpenedRef.current) { return; } - if (releaseQuery.data || releaseQuery.error) { + if (releaseCatalog.length || releaseListQuery.error) { setOpen(true); hasOpenedRef.current = true; } - }, [isEligible, releaseQuery.data, releaseQuery.error]); + }, [isEligible, releaseCatalog.length, releaseListQuery.error]); return ( { }} > - {`What's new in CIPP ${releaseMeta.currentTag}`} - + + + {`Release notes for ${releaseHeading}`} + + + + - {releaseName} - - The latest release notes are provided below. You can always read them on GitHub if you - prefer. - - {releaseQuery.isLoading ? ( + {releaseListQuery.error ? ( + + We couldn't load additional releases right now. The latest release notes are shown + below. + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ""} + + ) : null} + {gfmSupport.error ? ( + + Displaying these release notes without GitHub-flavoured markdown enhancements due to a + parsing issue. Formatting may look different. + + ) : null} + {isReleaseListLoading && !selectedReleaseData ? ( - ) : releaseQuery.error ? ( + ) : releaseListQuery.error ? ( We couldn't load the release notes right now. You can view them on GitHub instead. - {releaseQuery.error?.message ? ` (${releaseQuery.error.message})` : ""} + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ""} ) : ( - ( - - ), - img: ({ node, ...props }) => ( + ( + + + We couldn't format these release notes + {error?.message ? ` (${error.message})` : ""}. A plain-text version is shown + below. + - ), - }} - rehypePlugins={[rehypeRaw]} - remarkPlugins={[remarkGfm]} + component="pre" + sx={{ whiteSpace: "pre-wrap", fontFamily: "inherit", m: 0 }} + > + {releaseBody} + + + )} > - {formattedReleaseBody} - + ( + + ), + img: ({ node, ...props }) => ( + + ), + }} + rehypePlugins={[rehypeRaw]} + remarkPlugins={gfmSupport.plugins} + > + {formattedReleaseBody} + + )} @@ -243,9 +448,15 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { py: 2, }} > - + - } - actions={actions} - simpleColumns={[ - "Tenants", - "EventType", - "Conditions", - "RepeatsEvery", - "Actions", - "excludedTenants", - ]} - queryKey="ListAlertsQueue" - /> - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; +import { Button } from "@mui/material"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative. +import Link from "next/link"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { CopyAll, Delete, NotificationAdd } from "@mui/icons-material"; + +const Page = () => { + const pageTitle = "Alerts"; + const actions = [ + { + label: "View Task Details", + link: "/cipp/scheduler/task?id=[RowKey]", + icon: , + condition: (row) => row?.EventType === "Scheduled Task", + }, + { + label: "Edit Alert", + link: "/tenant/administration/alert-configuration/alert?id=[RowKey]", + icon: , + color: "success", + target: "_self", + }, + { + label: "Clone & Edit Alert", + link: "/tenant/administration/alert-configuration/alert?id=[RowKey]&clone=true", + icon: , + color: "success", + target: "_self", + }, + { + label: "Delete Alert", + type: "POST", + url: "/api/RemoveQueuedAlert", + data: { + ID: "RowKey", + EventType: "EventType", + }, + icon: , + relatedQueryKeys: "ListAlertsQueue", + confirmText: "Are you sure you want to delete this Alert?", + multiPost: false, + }, + ]; + + return ( + } + > + Add Alert + + } + actions={actions} + simpleColumns={[ + "Tenants", + "EventType", + "Conditions", + "RepeatsEvery", + "Actions", + "AlertComment", + "excludedTenants", + ]} + queryKey="ListAlertsQueue" + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; From 6ac7daf1ab07d47739784d55758b3d50cd8fde69 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:32:08 +0200 Subject: [PATCH 20/41] Update to add new mx alert --- src/data/alerts.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/data/alerts.json b/src/data/alerts.json index 7ca2114492e7..46ea51131e29 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -213,6 +213,12 @@ "recommendedRunInterval": "7d", "description": "Monitors domain security scores from the DomainAnalyser and alerts when scores fall below the specified threshold." }, + { + "name": "MXRecordChanged", + "label": "Alert on MX record changes", + "recommendedRunInterval": "4h", + "description": "Monitors MX records for all domains and alerts when changes are detected. This helps identify potential mail routing changes that could indicate security issues or unauthorized modifications." + }, { "name": "GlobalAdminNoAltEmail", "label": "Alert on Global Admin accounts without alternate email address", From c5ac3f32c685deea53fa5093d9a3478e0a0d1aa1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:32:27 +0200 Subject: [PATCH 21/41] 1d --- src/data/alerts.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/alerts.json b/src/data/alerts.json index 46ea51131e29..082fe856f297 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -216,7 +216,7 @@ { "name": "MXRecordChanged", "label": "Alert on MX record changes", - "recommendedRunInterval": "4h", + "recommendedRunInterval": "1d", "description": "Monitors MX records for all domains and alerts when changes are detected. This helps identify potential mail routing changes that could indicate security issues or unauthorized modifications." }, { From 8381c5ed91ff18aa515dc544219e64e324fe7314 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:57:44 +0200 Subject: [PATCH 22/41] allow selection of country for named locations --- .../conditional/list-named-locations/index.js | 29 ++++++++++++++++--- src/utils/get-cipp-formatting.js | 20 +++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/pages/tenant/conditional/list-named-locations/index.js b/src/pages/tenant/conditional/list-named-locations/index.js index 90bb4e3d43a2..8b19e50281cc 100644 --- a/src/pages/tenant/conditional/list-named-locations/index.js +++ b/src/pages/tenant/conditional/list-named-locations/index.js @@ -11,6 +11,7 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; import { LocationOn } from "@mui/icons-material"; +import countryList from "/src/data/countryList.json"; const Page = () => { const pageTitle = "Named Locations"; @@ -62,8 +63,18 @@ const Page = () => { namedLocationId: "id", change: "!addLocation", }, - fields: [{ type: "textField", name: "input", label: "Country Code" }], - confirmText: "Enter a two-letter country code, e.g., US.", + fields: [ + { + type: "autoComplete", + name: "input", + label: "Country", + options: countryList.map(({ Code, Name }) => ({ + value: Code, + label: `${Name} (${Code})`, + })), + }, + ], + confirmText: "Select a country to add to this named location.", condition: (row) => row["@odata.type"] == "#microsoft.graph.countryNamedLocation", }, { @@ -75,8 +86,18 @@ const Page = () => { namedLocationId: "id", change: "!removeLocation", }, - fields: [{ type: "textField", name: "input", label: "Country Code" }], - confirmText: "Enter a two-letter country code, e.g., US.", + fields: [ + { + type: "autoComplete", + name: "input", + label: "Country", + options: countryList.map(({ Code, Name }) => ({ + value: Code, + label: `${Name} (${Code})`, + })), + }, + ], + confirmText: "Select a country to remove from this named location.", condition: (row) => row["@odata.type"] == "#microsoft.graph.countryNamedLocation", }, { diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 8f44a222873e..53f738eb2111 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -32,6 +32,13 @@ import { getCippTranslation } from "./get-cipp-translation"; import DOMPurify from "dompurify"; import { getSignInErrorCodeTranslation } from "./get-cipp-signin-errorcode-translation"; import { CollapsibleChipList } from "../components/CippComponents/CollapsibleChipList"; +import countryList from "../data/countryList.json"; + +// Helper function to convert country codes to country names +const getCountryNameFromCode = (countryCode) => { + const country = countryList.find((c) => c.Code === countryCode); + return country ? country.Name : countryCode; +}; export const getCippFormatting = (data, cellName, type, canReceive, flatten = true) => { const isText = type === "text"; @@ -416,6 +423,19 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr return isText ? data : ; } + if (cellName === "countriesAndRegions") { + if (Array.isArray(data)) { + const countryNames = data + .filter((item) => item !== null && item !== undefined) + .map((countryCode) => getCountryNameFromCode(countryCode)); + + return isText ? countryNames.join(", ") : renderChipList(countryNames); + } else { + const countryName = getCountryNameFromCode(data); + return isText ? countryName : ; + } + } + if (cellName === "excludedTenants") { // Handle null or undefined data if (data === null || data === undefined) { From c18ddc44ab14361ba4fc0adf81162e157090574c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 17 Oct 2025 12:34:15 +0200 Subject: [PATCH 23/41] Feat: add visibility option to group settings form for M365 groups Resolves #4835 --- .../identity/administration/groups/edit.jsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pages/identity/administration/groups/edit.jsx b/src/pages/identity/administration/groups/edit.jsx index a052a7f28b30..9b2c70dd426f 100644 --- a/src/pages/identity/administration/groups/edit.jsx +++ b/src/pages/identity/administration/groups/edit.jsx @@ -44,6 +44,7 @@ const EditGroup = () => { RemoveOwner: [], AddContact: [], RemoveContact: [], + visibility: "Public", }, }); @@ -74,6 +75,7 @@ const EditGroup = () => { allowExternal: groupInfo?.data?.allowExternal, sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, + visibility: group?.visibility ?? "Public", displayName: group.displayName, description: group.description || "", membershipRules: group.membershipRule || "", @@ -114,6 +116,7 @@ const EditGroup = () => { sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, securityEnabled: group.securityEnabled, + visibility: group.visibility ?? "Public", }); // Reset the form with all values @@ -132,6 +135,7 @@ const EditGroup = () => { "sendCopies", "hideFromOutlookClients", "securityEnabled", + "visibility", ]; changeDetectionProperties.forEach((property) => { @@ -377,6 +381,24 @@ const EditGroup = () => { Group Settings + + {groupType === "Microsoft 365" && ( + + + + )} + {(groupType === "Microsoft 365" || groupType === "Distribution List") && ( Date: Fri, 17 Oct 2025 11:46:35 +0100 Subject: [PATCH 24/41] (bug): Adjusted data handling to handle cases where ForwardingAddress is an array --- .../administration/users/user/exchange.jsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 7e7919e4508f..eb51eeaff7f1 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -128,6 +128,16 @@ const Page = () => { }; } + // Handle arrays by joining them + if (Array.isArray(userIdentifier)) { + userIdentifier = userIdentifier.join(", "); + } + + // Ensure userIdentifier is a string + if (typeof userIdentifier !== "string") { + userIdentifier = String(userIdentifier); + } + // Handle special built-in cases if (userIdentifier === "Default" || userIdentifier === "Anonymous") { return { @@ -147,7 +157,7 @@ const Page = () => { // Exact match on display name (group.displayName && group.displayName === userIdentifier) || // Partial match - permission identifier starts with group display name (handles timestamps) - (group.displayName && userIdentifier?.startsWith(group.displayName)) + (group.displayName && userIdentifier.startsWith(group.displayName)) ); }); @@ -316,10 +326,15 @@ const Page = () => { useEffect(() => { if (userRequest.isSuccess && userRequest.data?.[0]) { const currentSettings = userRequest.data[0]; - const forwardingAddress = currentSettings.ForwardingAddress; + let forwardingAddress = currentSettings.ForwardingAddress; const forwardingSmtpAddress = currentSettings.MailboxActionsData?.ForwardingSmtpAddress; const forwardAndDeliver = currentSettings.ForwardAndDeliver; + // Handle ForwardingAddress being an array or string + if (Array.isArray(forwardingAddress)) { + forwardingAddress = forwardingAddress.join(", "); + } + let forwardingType = "disabled"; let cleanAddress = ""; From b133105c0f97ed7cdf0798b30f189e797e2fddcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sun, 19 Oct 2025 22:43:36 +0200 Subject: [PATCH 25/41] fix: update confirmation texts to include device names for clarity add condition to new filevault action --- src/pages/endpoint/MEM/devices/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index cb295245ff58..d4068f3e63f3 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -131,7 +131,7 @@ const Page = () => { GUID: "azureADDeviceId", }, condition: (row) => row.operatingSystem === "Windows", - confirmText: "Are you sure you want to retrieve the local admin password?", + confirmText: "Are you sure you want to retrieve the local admin password for [deviceName]?", }, { label: "Rotate Local Admin Password", @@ -154,7 +154,7 @@ const Page = () => { GUID: "azureADDeviceId", }, condition: (row) => row.operatingSystem === "Windows", - confirmText: "Are you sure you want to retrieve the BitLocker keys?", + confirmText: "Are you sure you want to retrieve the BitLocker keys for [deviceName]?", }, { label: "Retrieve File Fault Key", @@ -165,6 +165,7 @@ const Page = () => { GUID: "id", Action: "getFileVaultKey", }, + condition: (row) => row.operatingSystem === "macOS", confirmText: "Are you sure you want to retrieve the file vault key for [deviceName]?", }, { From 435bd8a73c063d480a13daa7c191aab77fa49d90 Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:56:29 +0800 Subject: [PATCH 26/41] Fixes for deploying new EXO rule When a condition was filled but then removed it was still apart of the post data, this ensures we strip that data if a condition or similar is removed from the list. --- src/pages/email/transport/new-rules/add.jsx | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/pages/email/transport/new-rules/add.jsx b/src/pages/email/transport/new-rules/add.jsx index 76083d0ca3d7..4e6029efc601 100644 --- a/src/pages/email/transport/new-rules/add.jsx +++ b/src/pages/email/transport/new-rules/add.jsx @@ -31,6 +31,107 @@ const AddTransportRule = () => { const exceptionTypeWatch = useWatch({ control: formControl.control, name: "exceptionType" }); const applyToAllMessagesWatch = useWatch({ control: formControl.control, name: "applyToAllMessages" }); + // Helper function to get field names for a condition + const getConditionFieldNames = (conditionValue) => { + const fields = [conditionValue]; + // Add related fields for special cases + if (conditionValue === "HeaderContainsWords") { + fields.push("HeaderContainsWordsMessageHeader"); + } else if (conditionValue === "HeaderMatchesPatterns") { + fields.push("HeaderMatchesPatternsMessageHeader"); + } + return fields; + }; + + // Helper function to get field names for an action + const getActionFieldNames = (actionValue) => { + const fields = []; + switch (actionValue) { + case "RejectMessage": + fields.push("RejectMessageReasonText", "RejectMessageEnhancedStatusCode"); + break; + case "SetHeader": + fields.push("SetHeaderName", "SetHeaderValue"); + break; + case "ApplyHtmlDisclaimer": + fields.push("ApplyHtmlDisclaimerText", "ApplyHtmlDisclaimerLocation", "ApplyHtmlDisclaimerFallbackAction"); + break; + default: + fields.push(actionValue); + } + return fields; + }; + + // Update selected conditions and clean up removed ones + useEffect(() => { + const newConditions = conditionTypeWatch || []; + const newConditionValues = newConditions.map(c => c.value || c); + const oldConditionValues = selectedConditions.map(c => c.value || c); + + // Find removed conditions + const removedConditions = oldConditionValues.filter( + oldVal => !newConditionValues.includes(oldVal) + ); + + // Clear form values for removed conditions + removedConditions.forEach(conditionValue => { + const fieldNames = getConditionFieldNames(conditionValue); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedConditions(newConditions); + }, [conditionTypeWatch]); + + // Update selected actions and clean up removed ones + useEffect(() => { + const newActions = actionTypeWatch || []; + const newActionValues = newActions.map(a => a.value || a); + const oldActionValues = selectedActions.map(a => a.value || a); + + // Find removed actions + const removedActions = oldActionValues.filter( + oldVal => !newActionValues.includes(oldVal) + ); + + // Clear form values for removed actions + removedActions.forEach(actionValue => { + const fieldNames = getActionFieldNames(actionValue); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedActions(newActions); + }, [actionTypeWatch]); + + // Update selected exceptions and clean up removed ones + useEffect(() => { + const newExceptions = exceptionTypeWatch || []; + const newExceptionValues = newExceptions.map(e => e.value || e); + const oldExceptionValues = selectedExceptions.map(e => e.value || e); + + // Find removed exceptions + const removedExceptions = oldExceptionValues.filter( + oldVal => !newExceptionValues.includes(oldVal) + ); + + // Clear form values for removed exceptions + removedExceptions.forEach(exceptionValue => { + // Get base condition name (remove ExceptIf prefix) + const baseCondition = exceptionValue.replace("ExceptIf", ""); + const fieldNames = getConditionFieldNames(baseCondition).map( + field => field.includes("MessageHeader") ? `ExceptIf${field}` : exceptionValue + ); + fieldNames.forEach(fieldName => { + formControl.setValue(fieldName, undefined); + }); + }); + + setSelectedExceptions(newExceptions); + }, [exceptionTypeWatch]); + // Update selected conditions when conditionType changes useEffect(() => { setSelectedConditions(conditionTypeWatch || []); From 5bea198066e10a733ebdf1731d4fc44f9bc6752f Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:53:18 +0200 Subject: [PATCH 27/41] Add dynamic tenant groups --- .../CippAddEditTenantGroups.jsx | 133 ++++++++----- .../CippAddTenantGroupDrawer.jsx | 121 ++++++++++++ .../CippTenantGroupRuleBuilder.jsx | 186 ++++++++++++++++++ .../administration/tenants/groups/edit.js | 169 ++++++++++++---- .../administration/tenants/groups/index.js | 34 ++-- src/utils/get-cipp-tenant-group-options.js | 183 +++++++++++++++++ 6 files changed, 728 insertions(+), 98 deletions(-) create mode 100644 src/components/CippComponents/CippAddTenantGroupDrawer.jsx create mode 100644 src/components/CippComponents/CippTenantGroupRuleBuilder.jsx create mode 100644 src/utils/get-cipp-tenant-group-options.js diff --git a/src/components/CippComponents/CippAddEditTenantGroups.jsx b/src/components/CippComponents/CippAddEditTenantGroups.jsx index 35696ef1fe52..9ba637d003c8 100644 --- a/src/components/CippComponents/CippAddEditTenantGroups.jsx +++ b/src/components/CippComponents/CippAddEditTenantGroups.jsx @@ -1,53 +1,96 @@ import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { Stack, Typography } from "@mui/material"; -import CippFormSection from "/src/components/CippFormPages/CippFormSection"; +import { Typography } from "@mui/material"; +import { Grid } from "@mui/system"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFormCondition } from "./CippFormCondition"; +import CippTenantGroupRuleBuilder from "./CippTenantGroupRuleBuilder"; -const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButtonTitle }) => { +const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButtonTitle, hideSubmitButton = false }) => { return ( - { - return { - ...values, - Action: "AddEdit", - }; - }} - initialValues={initialValues} - > + <> Properties - - - - - - + + + + + + + + + {/* Group Type Selection */} + + + + + {/* Static Group Members - Show only when Static is selected */} + + + + + + + {/* Dynamic Group Rules - Show only when Dynamic is selected */} + + + + + + + ); }; diff --git a/src/components/CippComponents/CippAddTenantGroupDrawer.jsx b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx new file mode 100644 index 000000000000..75804c15f89c --- /dev/null +++ b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from "react"; +import { Button, Box } from "@mui/material"; +import { useForm, useFormState } from "react-hook-form"; +import { GroupAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import CippAddEditTenantGroups from "./CippAddEditTenantGroups"; +import { getCippValidator } from "../../utils/get-cipp-validator"; + +export const CippAddTenantGroupDrawer = ({ + buttonText = "Add Tenant Group", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }, + }); + + const createTenantGroup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["TenantGroupListPage"], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (createTenantGroup.isSuccess) { + formControl.reset({ + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }); + } + }, [createTenantGroup.isSuccess]); + + const handleSubmit = (data) => { + const formattedData = { + ...data, + Action: "AddEdit", + }; + + // If it's a dynamic group, format the rules for the backend + if (data.groupType === "dynamic" && data.dynamicRules) { + formattedData.dynamicRules = data.dynamicRules.map(rule => ({ + property: rule.property?.value || rule.property, + operator: rule.operator?.value || rule.operator, + value: rule.value, + })); + formattedData.ruleLogic = data.ruleLogic || "and"; + } + + createTenantGroup.mutate({ + url: "/api/ExecTenantGroup", + data: formattedData, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + groupType: "static", + ruleLogic: "and", + dynamicRules: [{}] + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx new file mode 100644 index 000000000000..2964b81a0d83 --- /dev/null +++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx @@ -0,0 +1,186 @@ + import React, { useState } from "react"; +import { Box, Button, IconButton, Typography, Alert, Paper } from "@mui/material"; +import { Grid } from "@mui/system"; +import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { useWatch } from "react-hook-form"; +import { + getTenantGroupPropertyOptions, + getTenantGroupOperatorOptions, + getTenantGroupValueOptions, +} from "../../utils/get-cipp-tenant-group-options"; + +const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { + const [ruleCount, setRuleCount] = useState(1); + + // Watch the rules array to get current values + const watchedRules = useWatch({ + control: formControl.control, + name: name, + defaultValue: [{}], + }); + + // Watch the logic operator + const ruleLogic = useWatch({ + control: formControl.control, + name: "ruleLogic", + defaultValue: "and" + }); + + const propertyOptions = getTenantGroupPropertyOptions(); + + const addRule = () => { + const currentRules = formControl.getValues(name) || []; + const newRules = [...currentRules, {}]; + formControl.setValue(name, newRules); + setRuleCount(ruleCount + 1); + }; + + const removeRule = (index) => { + const currentRules = formControl.getValues(name) || []; + const newRules = currentRules.filter((_, i) => i !== index); + formControl.setValue(name, newRules); + setRuleCount(Math.max(1, ruleCount - 1)); + }; + + const getValueOptions = (ruleIndex) => { + const rules = watchedRules || []; + const rule = rules[ruleIndex]; + const propertyType = rule?.property?.type; + return getTenantGroupValueOptions(propertyType); + }; + + const getOperatorOptions = (ruleIndex) => { + const rules = watchedRules || []; + const rule = rules[ruleIndex]; + const propertyType = rule?.property?.type; + return getTenantGroupOperatorOptions(propertyType); + }; + + const renderRule = (ruleIndex) => { + const isFirstRule = ruleIndex === 0; + const canRemove = (watchedRules?.length || 0) > 1; + + return ( + + {!isFirstRule && ( + + {(ruleLogic || 'and').toUpperCase()} + + )} + + + {/* Property Selection */} + + + + + {/* Operator Selection */} + + + + + + + {/* Value Selection - Conditional based on property type */} + + + + + + + {/* Remove Rule Button */} + + {canRemove && ( + removeRule(ruleIndex)} size="small"> + + + )} + + + + ); + }; + + return ( + + + Dynamic Rules + + + + Define rules to automatically include tenants in this group. Rules are combined with the selected logic operator. + Example: "Available License equals Microsoft 365 E3" {(ruleLogic || 'and').toUpperCase()} "Delegated Access Status equals Direct Tenant" + + + {/* Logic Operator Selection */} + + + + + {/* Render existing rules */} + {(watchedRules || [{}]).map((_, index) => renderRule(index))} + + {/* Add Rule Button */} + + + + + ); +}; + +export default CippTenantGroupRuleBuilder; diff --git a/src/pages/tenant/administration/tenants/groups/edit.js b/src/pages/tenant/administration/tenants/groups/edit.js index 40326136d39e..fa52b69d553b 100644 --- a/src/pages/tenant/administration/tenants/groups/edit.js +++ b/src/pages/tenant/administration/tenants/groups/edit.js @@ -1,12 +1,10 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { useForm } from "react-hook-form"; -import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { ApiGetCall } from "/src/api/ApiCall"; import { useEffect } from "react"; import { useRouter } from "next/router"; import { Box } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippPageCard from "/src/components/CippCards/CippPageCard"; -import { CippApiResults } from "/src/components/CippComponents/CippApiResults"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; import CippAddEditTenantGroups from "/src/components/CippComponents/CippAddEditTenantGroups"; const Page = () => { @@ -21,53 +19,156 @@ const Page = () => { queryKey: id ? `TenantGroupProperties_${id}` : null, }); - const updateGroupApi = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: [ - `TenantGroupProperties_${id}`, - "TenantGroupListPage", - ], - }); - useEffect(() => { if (groupDetails.isSuccess && groupDetails.data) { + const groupData = groupDetails?.data?.Results?.[0]; + + // Determine if this is a dynamic or static group + const isDynamic = groupData?.GroupType === "dynamic" && groupData?.DynamicRules; + + // Format dynamic rules if they exist + let formattedDynamicRules = [{}]; + if (isDynamic && groupData.DynamicRules) { + try { + let rules; + if (Array.isArray(groupData.DynamicRules)) { + rules = groupData.DynamicRules; + } else if (typeof groupData.DynamicRules === "string") { + rules = JSON.parse(groupData.DynamicRules); + } else if (typeof groupData.DynamicRules === "object") { + rules = [groupData.DynamicRules]; + } else { + rules = []; + } + + formattedDynamicRules = rules.map((rule) => { + // Handle value - it's always an array of objects from the backend + let valueForForm; + if (Array.isArray(rule.value)) { + // If it's an array of objects, extract all values + valueForForm = rule.value.map((item) => ({ + label: item.label || item.value || item, + value: item.value || item, + })); + // For single selection operators, take just the first item + if (rule.operator === "eq" || rule.operator === "ne") { + valueForForm = valueForForm[0]; + } + } else if (typeof rule.value === "object" && rule.value?.value) { + // If it's a single object with a value property + valueForForm = { + label: rule.value.label || rule.value.value, + value: rule.value.value, + }; + } else { + // Simple value + valueForForm = { + label: rule.value, + value: rule.value, + }; + } + + return { + property: { + label: + rule.property === "availableLicense" + ? "Available License" + : rule.property === "availableServicePlan" + ? "Available Service Plan" + : rule.property === "delegatedAccessStatus" + ? "Delegated Access Status" + : rule.property, + value: rule.property, + type: + rule.property === "availableLicense" + ? "license" + : rule.property === "availableServicePlan" + ? "servicePlan" + : rule.property === "delegatedAccessStatus" + ? "delegatedAccess" + : "unknown", + }, + operator: { + label: + rule.operator === "eq" + ? "Equals" + : rule.operator === "ne" + ? "Not Equals" + : rule.operator === "in" + ? "In" + : rule.operator === "notIn" + ? "Not In" + : rule.operator, + value: rule.operator, + }, + value: valueForForm, + }; + }); + } catch (e) { + console.error("Error parsing dynamic rules:", e, groupData.DynamicRules); + formattedDynamicRules = [{}]; + } + } + formControl.reset({ groupId: id, - groupName: groupDetails?.data?.Results?.[0]?.Name ?? "", - groupDescription: groupDetails?.data?.Results?.[0]?.Description ?? "", - members: - groupDetails?.data?.Results?.[0]?.Members?.map((member) => ({ - label: member.displayName, - value: member.customerId, - })) || [], + groupName: groupData?.Name ?? "", + groupDescription: groupData?.Description ?? "", + groupType: isDynamic ? "dynamic" : "static", + ruleLogic: groupData?.RuleLogic || "and", + members: !isDynamic + ? groupData?.Members?.map((member) => ({ + label: member.displayName, + value: member.customerId, + })) || [] + : [], + dynamicRules: formattedDynamicRules, }); } }, [groupDetails.isSuccess, groupDetails.data]); + const customDataFormatter = (values) => { + const formattedData = { + ...values, + Action: "AddEdit", + }; + + // If it's a dynamic group, format the rules for the backend + if (values.groupType === "dynamic" && values.dynamicRules) { + formattedData.dynamicRules = values.dynamicRules.map((rule) => ({ + property: rule.property?.value || rule.property, + operator: rule.operator?.value || rule.operator, + value: rule.value, + })); + formattedData.ruleLogic = values.ruleLogic || "and"; + } + + return formattedData; + }; + return ( - - - - - - + - - - + ); }; diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js index 8d3e4c328ceb..64647625dc9c 100644 --- a/src/pages/tenant/administration/tenants/groups/index.js +++ b/src/pages/tenant/administration/tenants/groups/index.js @@ -2,15 +2,14 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { TabbedLayout } from "/src/layouts/TabbedLayout"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import tabOptions from "../tabOptions"; -import { Edit } from "@mui/icons-material"; -import { Button, SvgIcon } from "@mui/material"; -import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; -import NextLink from "next/link"; +import { Edit, PlayArrow } from "@mui/icons-material"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import { CippAddTenantGroupDrawer } from "/src/components/CippComponents/CippAddTenantGroupDrawer"; const Page = () => { const pageTitle = "Tenant Groups"; - const simpleColumns = ["Name", "Description", "Members"]; + const simpleColumns = ["Name", "Description", "GroupType", "Members"]; const actions = [ { @@ -18,6 +17,16 @@ const Page = () => { link: "/tenant/administration/tenants/groups/edit?id=[Id]", icon: , }, + { + label: "Run Dynamic Rules", + icon: , + url: "/api/ExecRunTenantGroupRule", + type: "POST", + data: { groupId: "Id" }, + queryKey: "TenantGroupListPage", + confirmText: "Are you sure you want to run dynamic rules for [Name]?", + condition: (row) => row.GroupType === 'dynamic' + }, { label: "Delete Group", icon: , @@ -39,20 +48,7 @@ const Page = () => { apiDataKey="Results" actions={actions} cardButton={ - + } /> ); diff --git a/src/utils/get-cipp-tenant-group-options.js b/src/utils/get-cipp-tenant-group-options.js new file mode 100644 index 000000000000..7a27e526c16c --- /dev/null +++ b/src/utils/get-cipp-tenant-group-options.js @@ -0,0 +1,183 @@ +import M365LicensesDefault from "../data/M365Licenses.json"; +import M365LicensesAdditional from "../data/M365Licenses-additional.json"; + +/** + * Get all available licenses for tenant group dynamic rules + * @returns {Array} Array of license options with label and value (SKU) + */ +export const getTenantGroupLicenseOptions = () => { + // Combine both license files + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; + + // Create unique licenses map using String_Id as key for better deduplication + const uniqueLicensesMap = new Map(); + + allLicenses.forEach((license) => { + if (license.String_Id && license.Product_Display_Name && license.GUID) { + // Use String_Id as the unique key since that's what we send to backend + const key = license.String_Id; + if (!uniqueLicensesMap.has(key)) { + uniqueLicensesMap.set(key, { + label: license.Product_Display_Name, + value: license.String_Id, + guid: license.GUID, + }); + } + } + }); + + // Convert to array and filter out incomplete entries + const licenseOptions = Array.from(uniqueLicensesMap.values()).filter( + (license) => license.label && license.value + ); + + // Additional deduplication by label to handle cases where different String_Ids have same display name + const uniqueByLabelMap = new Map(); + licenseOptions.forEach((license) => { + if (!uniqueByLabelMap.has(license.label)) { + uniqueByLabelMap.set(license.label, license); + } + }); + + return Array.from(uniqueByLabelMap.values()).sort((a, b) => a.label.localeCompare(b.label)); +}; + +/** + * Get all available service plans for tenant group dynamic rules + * @returns {Array} Array of unique service plan options with label and value + */ +export const getTenantGroupServicePlanOptions = () => { + // Combine both license files + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; + + // Create unique service plans map using Service_Plan_Name as key for better deduplication + const uniqueServicePlansMap = new Map(); + + allLicenses.forEach((license) => { + if ( + license.Service_Plan_Name && + license.Service_Plans_Included_Friendly_Names && + license.Service_Plan_Id + ) { + // Use Service_Plan_Name as the unique key since that's what we send to backend + const key = license.Service_Plan_Name; + if (!uniqueServicePlansMap.has(key)) { + uniqueServicePlansMap.set(key, { + label: license.Service_Plans_Included_Friendly_Names, + value: license.Service_Plan_Name, + id: license.Service_Plan_Id, + }); + } + } + }); + + // Convert to array and sort by display name, then deduplicate by label as well + const serviceOptions = Array.from(uniqueServicePlansMap.values()).filter( + (plan) => plan.label && plan.value + ); // Filter out any incomplete entries + + // Additional deduplication by label to handle cases where different service plan names have same friendly name + const uniqueByLabelMap = new Map(); + serviceOptions.forEach((plan) => { + if (!uniqueByLabelMap.has(plan.label)) { + uniqueByLabelMap.set(plan.label, plan); + } + }); + + return Array.from(uniqueByLabelMap.values()).sort((a, b) => a.label.localeCompare(b.label)); +}; + +/** + * Get delegated access status options for tenant group dynamic rules + * @returns {Array} Array of delegated access status options + */ +export const getTenantGroupDelegatedAccessOptions = () => { + return [ + { + label: "Granular Delegated Admin Privileges", + value: "granularDelegatedAdminPrivileges", + }, + { + label: "Direct Tenant", + value: "directTenant", + }, + ]; +}; + +/** + * Get all property options for dynamic tenant group rules + * @returns {Array} Array of property options for the rule builder + */ +export const getTenantGroupPropertyOptions = () => { + return [ + { + label: "Available License", + value: "availableLicense", + type: "license", + }, + { + label: "Available Service Plan", + value: "availableServicePlan", + type: "servicePlan", + }, + { + label: "Delegated Access Status", + value: "delegatedAccessStatus", + type: "delegatedAccess", + }, + ]; +}; + +/** + * Get operator options for dynamic tenant group rules + * @returns {Array} Array of operator options + */ +export const getTenantGroupOperatorOptions = (propertyType) => { + const baseOperators = [ + { + label: "Equals", + value: "eq", + }, + { + label: "Not Equals", + value: "ne", + } + ]; + + const arrayOperators = [ + { + label: "In", + value: "in", + }, + { + label: "Not In", + value: "notIn", + } + ]; + + // Delegated Access Status only supports equals/not equals + if (propertyType === "delegatedAccess") { + return baseOperators; + } + + // License and Service Plan support all operators + return [...baseOperators, ...arrayOperators]; +}; + +/** + * Get value options based on the selected property type + * @param {string} propertyType - The type of property (license, servicePlan, delegatedAccess) + * @returns {Array} Array of value options for the selected property type + */ +export const getTenantGroupValueOptions = (propertyType) => { + switch (propertyType) { + case "license": + return getTenantGroupLicenseOptions(); + case "servicePlan": + return getTenantGroupServicePlanOptions(); + case "delegatedAccess": + return getTenantGroupDelegatedAccessOptions(); + default: + return []; + } +}; From 5d0f9f7734815352d8e211ae9887161899c8d6e2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 22 Oct 2025 09:58:20 -0400 Subject: [PATCH 28/41] null safety on policy drawer fix issue when no templates exist --- src/components/CippComponents/CippPolicyDeployDrawer.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index 3702111ed47f..619f516496cd 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -30,7 +30,7 @@ export const CippPolicyDeployDrawer = ({ const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); useEffect(() => { if (CATemplates.isSuccess && watcher?.value) { - const template = CATemplates.data.find((template) => template.GUID === watcher.value); + const template = CATemplates.data?.find((template) => template.GUID === watcher.value); if (template) { const jsonTemplate = template.RAWJson ? JSON.parse(template.RAWJson) : null; setJSONData(jsonTemplate); @@ -129,7 +129,7 @@ export const CippPolicyDeployDrawer = ({ multiple={false} formControl={formControl} options={ - CATemplates.isSuccess + CATemplates.isSuccess && Array.isArray(CATemplates.data) ? CATemplates.data.map((template) => ({ label: template.Displayname, value: template.GUID, From e9f8f1dfbab09bd16eee996645000b0b39059805 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 22 Oct 2025 15:55:13 -0400 Subject: [PATCH 29/41] feat: triggered tasks add delta query support to scheduler --- .../CippGraphAttributeSelector.jsx | 77 ++ .../CippGraphResourceSelector.jsx | 86 ++ .../CippScheduledTaskActions.jsx | 20 +- .../CippComponents/CippSchedulerDrawer.jsx | 85 ++ .../CippComponents/ScheduledTaskDetails.jsx | 25 + .../CippFormPages/CippSchedulerForm.jsx | 1001 +++++++++++++---- .../CippTable/CIPPTableToptoolbar.js | 3 + src/pages/cipp/scheduler/index.js | 103 +- 8 files changed, 1157 insertions(+), 243 deletions(-) create mode 100644 src/components/CippComponents/CippGraphAttributeSelector.jsx create mode 100644 src/components/CippComponents/CippGraphResourceSelector.jsx create mode 100644 src/components/CippComponents/CippSchedulerDrawer.jsx diff --git a/src/components/CippComponents/CippGraphAttributeSelector.jsx b/src/components/CippComponents/CippGraphAttributeSelector.jsx new file mode 100644 index 000000000000..3c3d9231342f --- /dev/null +++ b/src/components/CippComponents/CippGraphAttributeSelector.jsx @@ -0,0 +1,77 @@ +import { useWatch } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; + +/** + * A form component for selecting attributes from a Graph API endpoint + * @param {Object} props - Component props + * @param {Object} props.formControl - React Hook Form control object + * @param {string} props.name - Field name for the form + * @param {string} props.resourceFieldName - Name of the field that contains the selected resource type + * @param {string} props.label - Label for the field + * @param {string} props.helperText - Helper text for the field + * @param {boolean} props.multiple - Whether to allow multiple selections + * @param {boolean} props.required - Whether the field is required + * @param {Object} props.gridProps - Grid props to pass to the wrapper + */ +const CippGraphAttributeSelector = ({ + formControl, + name, + resourceFieldName = "DeltaResource", + label = "Attributes to Monitor", + helperText, + multiple = true, + required = false, + ...otherProps +}) => { + // Watch for changes in the resource type field + const selectedResource = useWatch({ + control: formControl.control, + name: resourceFieldName, + }); + + // Extract the value whether selectedResource is an object or string + const resourceValue = selectedResource?.value || selectedResource; + + const getHelperText = () => { + if (helperText) return helperText; + + if (!resourceValue) { + return "Select a resource type above to view available attributes"; + } + + return "Select which attributes to monitor for changes"; + }; + + const api = resourceValue + ? { + url: "/api/ListGraphRequest", + queryKey: `graph-properties-${resourceValue}`, + data: { + Endpoint: resourceValue, + ListProperties: true, + IgnoreErrors: true, + }, + labelField: (item) => item, + valueField: (item) => item, + dataKey: "Results", + } + : null; + + return ( + + ); +}; + +export default CippGraphAttributeSelector; diff --git a/src/components/CippComponents/CippGraphResourceSelector.jsx b/src/components/CippComponents/CippGraphResourceSelector.jsx new file mode 100644 index 000000000000..1efdf9479e7f --- /dev/null +++ b/src/components/CippComponents/CippGraphResourceSelector.jsx @@ -0,0 +1,86 @@ +import { useWatch } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; + +/** + * A form component for selecting specific resources from a Graph API endpoint + * @param {Object} props - Component props + * @param {Object} props.formControl - React Hook Form control object + * @param {string} props.name - Field name for the form + * @param {string} props.resourceFieldName - Name of the field that contains the selected resource type + * @param {string} props.label - Label for the field + * @param {string} props.helperText - Helper text for the field + * @param {boolean} props.multiple - Whether to allow multiple selections + * @param {boolean} props.required - Whether the field is required + * @param {Object} props.gridProps - Grid props to pass to the wrapper + */ +const CippGraphResourceSelector = ({ + formControl, + name, + resourceFieldName = "DeltaResource", + label = "Filter Specific Resources (Optional)", + helperText, + multiple = true, + required = false, + tenantFilter, + ...otherProps +}) => { + // Watch for changes in the resource type field + const selectedResource = useWatch({ + control: formControl.control, + name: resourceFieldName, + }); + + // Extract the value whether selectedResource is an object or string + const resourceValue = selectedResource?.value || selectedResource; + + const getHelperText = () => { + if (helperText) return helperText; + + if (!resourceValue) { + return "Select a resource type above to filter specific resources"; + } + + if (multiple) { + return "Optionally select specific resources to monitor (will create filter with OR statements: id eq 'id1' or id eq 'id2')"; + } + + return "Optionally select a specific resource to monitor"; + }; + + const api = resourceValue + ? { + url: "/api/ListGraphRequest", + queryKey: `graph-resources-${resourceValue}-${tenantFilter}`, + data: { + Endpoint: resourceValue, + IgnoreErrors: true, + $select: "id,displayName", + $top: 100, + tenantFilter, + }, + labelField: (item) => item.displayName || item.id, + valueField: "id", + dataKey: "Results", + waiting: tenantFilter ? true : false, + } + : null; + + return ( + + ); +}; + +export default CippGraphResourceSelector; diff --git a/src/components/CippComponents/CippScheduledTaskActions.jsx b/src/components/CippComponents/CippScheduledTaskActions.jsx index 2a1ec00236ff..2df628a81cdb 100644 --- a/src/components/CippComponents/CippScheduledTaskActions.jsx +++ b/src/components/CippComponents/CippScheduledTaskActions.jsx @@ -2,7 +2,7 @@ import { EyeIcon, TrashIcon } from "@heroicons/react/24/outline"; import { CopyAll, Edit, PlayArrow } from "@mui/icons-material"; import { usePermissions } from "../../hooks/use-permissions"; -export const CippScheduledTaskActions = () => { +export const CippScheduledTaskActions = (drawerHandlers = {}) => { const { checkPermissions } = usePermissions(); const canWriteScheduler = checkPermissions(["CIPP.Scheduler.ReadWrite"]); const canReadScheduler = checkPermissions(["CIPP.Scheduler.Read", "CIPP.Scheduler.ReadWrite"]); @@ -26,20 +26,32 @@ export const CippScheduledTaskActions = () => { }, { label: "Edit Job", - link: "/cipp/scheduler/job?id=[RowKey]", + customFunction: + drawerHandlers.openEditDrawer || + ((row) => { + // Fallback to page navigation if no drawer handler provided + window.location.href = `/cipp/scheduler/job?id=${row.RowKey}`; + }), multiPost: false, icon: , color: "success", showInActionsMenu: true, + noConfirm: true, condition: () => canWriteScheduler, }, { - label: "Clone and Edit Job", - link: "/cipp/scheduler/job?id=[RowKey]&Clone=True", + label: "Clone Job", + customFunction: + drawerHandlers.openCloneDrawer || + ((row) => { + // Fallback to page navigation if no drawer handler provided + window.location.href = `/cipp/scheduler/job?id=${row.RowKey}&Clone=True`; + }), multiPost: false, icon: , color: "success", showInActionsMenu: true, + noConfirm: true, condition: () => canWriteScheduler, }, { diff --git a/src/components/CippComponents/CippSchedulerDrawer.jsx b/src/components/CippComponents/CippSchedulerDrawer.jsx new file mode 100644 index 000000000000..1061b24a6f1d --- /dev/null +++ b/src/components/CippComponents/CippSchedulerDrawer.jsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from "react"; +import { Button, Box, Typography, Alert, AlertTitle } from "@mui/material"; +import { useForm, useFormState } from "react-hook-form"; +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippSchedulerForm from "../CippFormPages/CippSchedulerForm"; +import { useSettings } from "../../hooks/use-settings"; + +export const CippSchedulerDrawer = ({ + buttonText = "Add Task", + requiredPermissions = [], + PermissionButton = Button, + onSuccess, + taskId = null, + cloneMode = false, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + Recurrence: { value: "0", label: "Once" }, + taskType: { value: "scheduled", label: "Scheduled Task" }, + }, + }); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + // Reset form to default values + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + Recurrence: { value: "0", label: "Once" }, + taskType: { value: "scheduled", label: "Scheduled Task" }, + }); + }; + + const handleOpenDrawer = () => { + setDrawerVisible(true); + }; + + // Auto-open drawer if taskId is provided (for edit mode) + useEffect(() => { + if (taskId) { + setDrawerVisible(true); + } + }, [taskId]); + + return ( + <> + } + > + {buttonText} + + + + + Task Configuration + {taskId && cloneMode + ? "Clone this task with the same configuration. Modify the settings as needed and save to create a new task." + : taskId + ? "Edit the task configuration. Changes will be applied when you save." + : "Create a scheduled task or event-triggered task. Scheduled tasks run PowerShell commands at specified times, while triggered tasks respond to events like Azure AD changes."} + + + + + + + ); +}; diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 8e3f50161694..554c72261c4b 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -118,6 +118,31 @@ const ScheduledTaskDetails = ({ data, showActions = true }) => { isFetching={taskDetailResults.isFetching} /> + {taskDetails?.Task?.Trigger && ( + + }> + Trigger Configuration + + + { + return { + label: key, + value: getCippFormatting(value, key), + }; + })} + isFetching={taskDetailResults.isFetching} + /> + + + )} + {taskDetailResults.isFetching ? ( ) : ( diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index 049a554b8626..f46f29fbd93e 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -1,20 +1,39 @@ -import { Box, Button, Divider, Skeleton, SvgIcon, Typography } from "@mui/material"; +import { + Box, + Button, + Divider, + Skeleton, + SvgIcon, + Typography, + ButtonGroup, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + Alert, +} from "@mui/material"; import { Grid } from "@mui/system"; import { useWatch } from "react-hook-form"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; +import CippGraphResourceSelector from "/src/components/CippComponents/CippGraphResourceSelector"; +import CippGraphAttributeSelector from "/src/components/CippComponents/CippGraphAttributeSelector"; import { getCippValidator } from "/src/utils/get-cipp-validator"; import { useRouter } from "next/router"; +import Link from "next/link"; import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import CippFormInputArray from "../CippComponents/CippFormInputArray"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +import { ExpandMoreOutlined, Delete, Add } from "@mui/icons-material"; const CippSchedulerForm = (props) => { - const { formControl, fullWidth = false } = props; // Added fullWidth prop + const { formControl, fullWidth = false, taskId = null, cloneMode = false } = props; const selectedCommand = useWatch({ control: formControl.control, name: "command" }); + const [addedConditions, setAddedConditions] = useState([{ id: 0 }]); + const [isResourcePickerDisabled, setIsResourcePickerDisabled] = useState(false); const fieldRequired = (field) => { if (field?.Required) { @@ -26,6 +45,17 @@ const CippSchedulerForm = (props) => { } }; + const handleAddCondition = () => { + setAddedConditions([...addedConditions, { id: addedConditions.length }]); + }; + + const handleRemoveCondition = (id) => { + const currentConditions = formControl.getValues("Trigger.DeltaConditions") || []; + const updatedConditions = currentConditions.filter((_, index) => index !== id); + formControl.setValue("Trigger.DeltaConditions", updatedConditions); + setAddedConditions(addedConditions.filter((condition, index) => index !== id)); + }; + const postCall = ApiPostCall({ datafromUrl: true, relatedQueryKeys: [ @@ -57,18 +87,97 @@ const CippSchedulerForm = (props) => { { value: "30d", label: "Every 30 days" }, { value: "365d", label: "Every 365 days" }, ]; + + const triggerRecurrenceOptions = [ + { value: "15m", label: "Every 15 minutes" }, + { value: "30m", label: "Every 30 minutes" }, + { value: "1h", label: "Every 1 hour" }, + { value: "4h", label: "Every 4 hours" }, + { value: "12h", label: "Every 12 hours" }, + { value: "1d", label: "Every 1 day" }, + ]; + + const taskTypeOptions = [ + { value: "scheduled", label: "Scheduled Task" }, + { value: "triggered", label: "Triggered Task" }, + ]; + + const triggerTypeOptions = [{ value: "DeltaQuery", label: "Delta Query" }]; + + const deltaResourceOptions = [ + { value: "users", label: "Users" }, + { value: "groups", label: "Groups" }, + { value: "contacts", label: "Contacts" }, + { value: "orgContact", label: "Organizational Contacts" }, + { value: "devices", label: "Devices" }, + { value: "applications", label: "Applications" }, + { value: "servicePrincipals", label: "Service Principals" }, + { value: "directoryObjects", label: "Directory Objects" }, + { value: "directoryRole", label: "Directory Roles" }, + { value: "administrativeUnits", label: "Administrative Units" }, + { value: "oAuth2PermissionGrant", label: "OAuth2 Permission Grants" }, + ]; + + const simpleEventOptions = [ + { value: "created", label: "Resource Created" }, + { value: "updated", label: "Resource Updated" }, + { value: "deleted", label: "Resource Deleted" }, + ]; + + const operatorOptions = [ + { value: "eq", label: "Equals to" }, + { value: "ne", label: "Not Equals to" }, + { value: "like", label: "Like" }, + { value: "notlike", label: "Not like" }, + { value: "notmatch", label: "Does not match" }, + { value: "gt", label: "Greater than" }, + { value: "lt", label: "Less than" }, + { value: "in", label: "In" }, + { value: "notIn", label: "Not In" }, + ]; + + // Watch for trigger-related fields + const selectedTaskType = useWatch({ control: formControl.control, name: "taskType" }); + const selectedTriggerType = useWatch({ control: formControl.control, name: "Trigger.Type" }); + const selectedDeltaResource = useWatch({ + control: formControl.control, + name: "Trigger.DeltaResource", + }); + const selectedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // Watch for summary display + const selectedSimpleEvent = useWatch({ control: formControl.control, name: "Trigger.EventType" }); + const selectedRecurrence = useWatch({ control: formControl.control, name: "Recurrence" }); + const selectedScheduledTime = useWatch({ control: formControl.control, name: "ScheduledTime" }); + const selectedExecutePerResource = useWatch({ + control: formControl.control, + name: "Trigger.ExecutePerResource", + }); + const selectedDeltaExecutionMode = useWatch({ + control: formControl.control, + name: "Trigger.ExecutionMode", + }); + const selectedUseConditions = useWatch({ + control: formControl.control, + name: "Trigger.UseConditions", + }); + const selectedDeltaConditions = useWatch({ + control: formControl.control, + name: "Trigger.DeltaConditions", + }); const commands = ApiGetCall({ url: "/api/ListFunctionParameters?Module=CIPPCore", queryKey: "ListCommands", }); const router = useRouter(); + const scheduledTaskList = ApiGetCall({ url: "/api/ListScheduledItems", - queryKey: "ListScheduledItems-Edit-" + router.query.id, - waiting: !!router.query.id, + queryKey: "ListScheduledItems-Edit-" + (taskId || router.query.id), + waiting: !!(taskId || router.query.id), data: { - Id: router.query.id, + Id: taskId || router.query.id, }, }); @@ -76,19 +185,113 @@ const CippSchedulerForm = (props) => { url: "/api/ListTenants?AllTenantSelector=true", queryKey: "ListTenants-AllTenants", }); + + // Check if resource picker should be disabled useEffect(() => { - if (scheduledTaskList.isSuccess && router.query.id) { - const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); + console.log(selectedTenant); + if (!selectedTenant) { + setIsResourcePickerDisabled(false); + return; + } + + // Disable if AllTenants is selected + if (selectedTenant.value === "AllTenants") { + setIsResourcePickerDisabled(true); + return; + } + + // Disable if a tenant group is selected (groups have type: "Group") + if (selectedTenant.type === "Group") { + setIsResourcePickerDisabled(true); + return; + } + + setIsResourcePickerDisabled(false); + }, [selectedTenant]); + + // Helper functions for accordion summaries + const getTriggerSummary = () => { + if (!selectedTriggerType || selectedTaskType?.value !== "triggered") return ""; + + let summary = selectedTriggerType.label; + + if (selectedTriggerType.value === "DeltaQuery") { + if (selectedDeltaResource?.label) { + summary += ` - ${selectedDeltaResource.label}`; + } + if (selectedSimpleEvent?.label) { + summary += ` (${selectedSimpleEvent.label})`; + } + if (selectedUseConditions && selectedDeltaConditions?.length > 0) { + summary += ` with ${selectedDeltaConditions.length} condition${ + selectedDeltaConditions.length > 1 ? "s" : "" + }`; + } + } + + return summary; + }; + + const getScheduleSummary = () => { + if (selectedTaskType?.value !== "scheduled") return ""; + + let summary = ""; + if (selectedScheduledTime) { + // Handle both Unix timestamp and regular date formats + let date; + if ( + typeof selectedScheduledTime === "number" || + (typeof selectedScheduledTime === "string" && /^\d+$/.test(selectedScheduledTime)) + ) { + // Unix timestamp (seconds or milliseconds) + const timestamp = parseInt(selectedScheduledTime); + date = new Date(timestamp > 1000000000000 ? timestamp : timestamp * 1000); + } else { + date = new Date(selectedScheduledTime); + } + // Include both date and time + summary += `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + } + if (selectedRecurrence) { + summary += summary ? ` - ${selectedRecurrence.label}` : selectedRecurrence.label; + } + + return summary; + }; + + const getCommandSummary = () => { + if (!selectedCommand) return ""; + + let summary = selectedCommand.label; + + if (selectedTaskType?.value === "triggered" && selectedTriggerType?.value === "DeltaQuery") { + if (selectedExecutePerResource) { + summary += " (per resource)"; + } + if (selectedDeltaExecutionMode) { + summary += ` - ${selectedDeltaExecutionMode.label}`; + } + } + + return summary; + }; + useEffect(() => { + if (scheduledTaskList.isSuccess && (taskId || router.query.id)) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); // Early return if task is not found if (!task) { - console.warn(`Task with RowKey ${router.query.id} not found`); + console.warn(`Task with RowKey ${taskId || router.query.id} not found`); return; } - const postExecution = task?.postExecution?.split(",").map((item) => { - return { label: item, value: item }; - }); + const postExecution = task?.PostExecution + ? task.PostExecution.split(",").map((item) => { + return { label: item.trim(), value: item.trim() }; + }) + : []; // Find tenantFilter in tenantList, and create a label/value pair for the autocomplete if (tenantList.isSuccess) { @@ -180,13 +383,29 @@ const CippSchedulerForm = (props) => { const ResetParams = { tenantFilter: tenantFilterForForm, - RowKey: router.query.Clone ? null : task.RowKey, - Name: router.query.Clone ? `${task.Name} (Clone)` : task?.Name, + RowKey: router.query.Clone || cloneMode ? null : task.RowKey, + Name: router.query.Clone || cloneMode ? `${task.Name} (Clone)` : task?.Name, command: { label: task.Command, value: task.Command, addedFields: commandForForm }, ScheduledTime: task.ScheduledTime, Recurrence: recurrence, parameters: task.Parameters, postExecution: postExecution, + // Set task type based on whether trigger exists + taskType: task.Trigger + ? { value: "triggered", label: "Triggered Task" } + : { value: "scheduled", label: "Scheduled Task" }, + // Trigger configuration - use the trigger data directly since it's already in the correct format + ...(task.Trigger && { + "Trigger.Type": task.Trigger.Type, + "Trigger.DeltaResource": task.Trigger.DeltaResource, + "Trigger.EventType": task.Trigger.EventType, + "Trigger.ResourceFilter": task.Trigger.ResourceFilter || [], + "Trigger.WatchedAttributes": task.Trigger.WatchedAttributes || [], + "Trigger.UseConditions": task.Trigger.UseConditions || false, + "Trigger.DeltaConditions": task.Trigger.DeltaConditions || [], + "Trigger.ExecutePerResource": task.Trigger.ExecutePerResource || false, + "Trigger.ExecutionMode": task.Trigger.ExecutionMode, + }), // Show advanced parameters if: // 1. RawJsonParameters exist // 2. It's a system command with no defined parameters @@ -200,14 +419,32 @@ const CippSchedulerForm = (props) => { RawJsonParameters: task.RawJsonParameters || "", }; formControl.reset(ResetParams); + + // Set up condition builder if task has delta conditions + if ( + task.Trigger?.DeltaConditions && + Array.isArray(task.Trigger.DeltaConditions) && + task.Trigger.DeltaConditions.length > 0 + ) { + const conditionsWithIds = task.Trigger.DeltaConditions.map((condition, index) => ({ + id: index, + ...condition, + })); + setAddedConditions(conditionsWithIds); + } else { + // Reset to default single condition if no conditions exist + setAddedConditions([{ id: 0 }]); + } } } } }, [ + taskId, router.query.id, scheduledTaskList.isSuccess, tenantList.isSuccess, router.query.Clone, + cloneMode, commands.isSuccess, ]); @@ -230,8 +467,10 @@ const CippSchedulerForm = (props) => { // Get the original task parameters if we're editing (to preserve complex objects) let parametersToUse = null; - if (router.query.id && scheduledTaskList.isSuccess) { - const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); if (task?.Parameters) { parametersToUse = task.Parameters; } @@ -258,7 +497,7 @@ const CippSchedulerForm = (props) => { formControl.setValue("RawJsonParameters", "{}"); } } - }, [advancedParameters, router.query.id, scheduledTaskList.isSuccess]); + }, [advancedParameters, taskId, router.query.id, scheduledTaskList.isSuccess]); const gridSize = fullWidth ? 12 : 4; // Adjust size based on fullWidth prop @@ -268,6 +507,7 @@ const CippSchedulerForm = (props) => { {(scheduledTaskList.isFetching || tenantList.isLoading || commands.isLoading) && ( )} + {/* Top section: Tenant and Task Name */} { name="Name" label="Task Name" formControl={formControl} - /> - - - - { - const baseOptions = - commands.data?.map((command) => { - return { - label: command.Function, - value: command.Function, - addedFields: command, - }; - }) || []; - - // If we're editing a task and the command isn't in the base options, add it - if (router.query.id && scheduledTaskList.isSuccess) { - const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); - if (task?.Command && !baseOptions.find((opt) => opt.value === task.Command)) { - baseOptions.unshift({ - label: task.Command, - value: task.Command, - addedFields: { - Function: task.Command, - Parameters: [], - }, - }); - } - } - - return baseOptions; - })()} - validators={{ - validate: (value) => { - if (!value) { - return "Please select a Command"; - } - return true; - }, - }} - /> - - - - + + { - let options = [...recurrenceOptions]; - - // If we're editing a task and the recurrence isn't in the base options, add it - if (router.query.id && scheduledTaskList.isSuccess) { - const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); - if (task?.Recurrence && !options.find((opt) => opt.value === task.Recurrence)) { - options.push({ - value: task.Recurrence, - label: `Custom: ${task.Recurrence}`, - }); - } - } - - return options; - })()} - multiple={false} - disableClearable={true} - creatable={true} + multiple + creatable={false} + options={[ + { label: "Webhook", value: "Webhook" }, + { label: "Email", value: "Email" }, + { label: "PSA", value: "PSA" }, + ]} /> - {selectedCommand?.addedFields?.Synopsis && ( - - - PowerShell Command: - - {selectedCommand.addedFields.Synopsis} - - - - )} - {selectedCommand?.addedFields?.Parameters?.map((param, idx) => ( - - - {param.Type === "System.Boolean" || - param.Type === "System.Management.Automation.SwitchParameter" ? ( - - ) : param.Type === "System.Collections.Hashtable" ? ( - - ) : param.Type?.startsWith("System.String") ? ( - - ) : ( - - )} - - - ))} + {/* Divider */} - + + + {/* Task Type Selection */} - + + {taskTypeOptions.map((option) => ( + + ))} + + + {/* Trigger Configuration Accordion */} - getCippValidator(value, "json"), - }} - formControl={formControl} - multiline - rows={6} - maxRows={30} - sx={{ - "& .MuiInputBase-root": { - overflow: "auto", - minHeight: "200px", - }, - }} - placeholder={`Enter a JSON object`} - /> + + }> + + Trigger Configuration + {getTriggerSummary() && ( + + - {getTriggerSummary()} + + )} + + + + + + + + + {/* Delta Query Configuration */} + + + + + Delta queries track changes to Microsoft Graph resources. Learn more about{" "} + + delta query concepts and usage + {" "} + in the Microsoft documentation. + + + + + + + + + + + + + + + + + + + + + + + {/* Condition Builder for all event types */} + + + + + + + + + Delta Query Conditions + + + + Create PowerShell-style Where-Object conditions to filter delta query + results. Each condition compares a resource property against a specific + value. Multiple conditions work as AND logic - all must be true to trigger + the task. + + + + {addedConditions.map((condition, index) => ( + + + + + + + + + + + + handleRemoveCondition(index)} color="error"> + + + + + ))} + + + {/* Delta Query Execution Options */} + + + + + + + + + {/* Trigger Recurrence */} + + + + + + + + + + + {/* Schedule Configuration - Only for scheduled tasks */} + + + + }> + + Schedule Configuration + {getScheduleSummary() && ( + + - {getScheduleSummary()} + + )} + + + + + + + + + { + let options = [...recurrenceOptions]; + + // If we're editing a task and the recurrence isn't in the base options, add it + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + if ( + task?.Recurrence && + !options.find((opt) => opt.value === task.Recurrence) + ) { + options.push({ + value: task.Recurrence, + label: `Custom: ${task.Recurrence}`, + }); + } + } + + return options; + })()} + multiple={false} + disableClearable={true} + creatable={true} + /> + + + + + + {/* Command & Parameters - For both scheduled and triggered tasks */} - + + }> + + Command & Parameters + {getCommandSummary() && ( + + - {getCommandSummary()} + + )} + + + + + {/* Command selection for both scheduled and triggered tasks */} + + { + const baseOptions = + commands.data?.map((command) => { + return { + label: command.Function, + value: command.Function, + addedFields: command, + }; + }) || []; + + // If we're editing a task and the command isn't in the base options, add it + if ((taskId || router.query.id) && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find( + (task) => task.RowKey === (taskId || router.query.id) + ); + if ( + task?.Command && + !baseOptions.find((opt) => opt.value === task.Command) + ) { + baseOptions.unshift({ + label: task.Command, + value: task.Command, + addedFields: { + Function: task.Command, + Parameters: [], + }, + }); + } + } + + return baseOptions; + })()} + validators={{ + validate: (value) => { + if (!value) { + return "Please select a Command"; + } + return true; + }, + }} + /> + + + {selectedCommand?.addedFields?.Synopsis && ( + + + PowerShell Command: + + {selectedCommand.addedFields.Synopsis} + + + + )} + + {selectedCommand?.addedFields?.Parameters?.map((param, idx) => ( + + + {param.Type === "System.Boolean" || + param.Type === "System.Management.Automation.SwitchParameter" ? ( + + ) : param.Type === "System.Collections.Hashtable" ? ( + + ) : param.Type?.startsWith("System.String") ? ( + + ) : ( + + )} + + + ))} + + + + + + + + getCippValidator(value, "json"), + }} + formControl={formControl} + multiline + rows={6} + maxRows={30} + sx={{ + "& .MuiInputBase-root": { + overflow: "auto", + minHeight: "200px", + }, + }} + placeholder={`Enter a JSON object`} + /> + + + + + + diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index f4374fbf64b5..321e53e1d7b2 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -475,6 +475,9 @@ export const CIPPTableToptoolbar = ({ } setCurrentEffectiveQueryKey(queryKey || title); // Reset to original query key setActiveFilterName(null); // Clear active filter + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { type: "reset", value: null, name: null }); + } } if (filterType === "graph") { const filterProps = [ diff --git a/src/pages/cipp/scheduler/index.js b/src/pages/cipp/scheduler/index.js index b7071474cad1..6af3d936f2fc 100644 --- a/src/pages/cipp/scheduler/index.js +++ b/src/pages/cipp/scheduler/index.js @@ -1,14 +1,26 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippTablePage from "/src/components/CippComponents/CippTablePage"; import { Button } from "@mui/material"; -import Link from "next/link"; import { CalendarDaysIcon } from "@heroicons/react/24/outline"; import { useState } from "react"; import ScheduledTaskDetails from "../../../components/CippComponents/ScheduledTaskDetails"; import { CippScheduledTaskActions } from "../../../components/CippComponents/CippScheduledTaskActions"; +import { CippSchedulerDrawer } from "../../../components/CippComponents/CippSchedulerDrawer"; const Page = () => { - const actions = CippScheduledTaskActions(); + const [editTaskId, setEditTaskId] = useState(null); + const [cloneTaskId, setCloneTaskId] = useState(null); + + const drawerHandlers = { + openEditDrawer: (row) => { + setEditTaskId(row.RowKey); + }, + openCloneDrawer: (row) => { + setCloneTaskId(row.RowKey); + }, + }; + + const actions = CippScheduledTaskActions(drawerHandlers); const filterList = [ { @@ -40,39 +52,60 @@ const Page = () => { }; const [showHiddenJobs, setShowHiddenJobs] = useState(false); return ( - - - - - } - tenantInTitle={false} - title="Scheduled Tasks" - apiUrl={ - showHiddenJobs ? "/api/ListScheduledItems?ShowHidden=true" : "/api/ListScheduledItems" - } - queryKey={showHiddenJobs ? `ListScheduledItems-hidden` : `ListScheduledItems`} - simpleColumns={[ - "ExecutedTime", - "TaskState", - "Tenant", - "Name", - "ScheduledTime", - "Command", - "Parameters", - "PostExecution", - "Recurrence", - "Results", - ]} - actions={actions} - offCanvas={offCanvas} - filters={filterList} - /> + <> + + + + + } + tenantInTitle={false} + title="Scheduled Tasks" + apiUrl={ + showHiddenJobs ? "/api/ListScheduledItems?ShowHidden=true" : "/api/ListScheduledItems" + } + queryKey={showHiddenJobs ? `ListScheduledItems-hidden` : `ListScheduledItems`} + simpleColumns={[ + "ExecutedTime", + "TaskState", + "Tenant", + "Name", + "ScheduledTime", + "Command", + "Parameters", + "PostExecution", + "Recurrence", + "Results", + ]} + actions={actions} + offCanvas={offCanvas} + filters={filterList} + /> + + {/* Edit Drawer */} + {editTaskId && ( + setEditTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} + + {/* Clone Drawer */} + {cloneTaskId && ( + setCloneTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} + ); }; From 9c3ba843c2c1c2362a63c569810c1293b7a94bbc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 22 Oct 2025 16:16:35 -0400 Subject: [PATCH 30/41] fix state for edit schedule drawer --- .../CippComponents/CippSchedulerDrawer.jsx | 24 ++++++++++++++----- src/pages/cipp/scheduler/index.js | 2 ++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/CippComponents/CippSchedulerDrawer.jsx b/src/components/CippComponents/CippSchedulerDrawer.jsx index 1061b24a6f1d..98510e77ce53 100644 --- a/src/components/CippComponents/CippSchedulerDrawer.jsx +++ b/src/components/CippComponents/CippSchedulerDrawer.jsx @@ -11,10 +11,12 @@ export const CippSchedulerDrawer = ({ requiredPermissions = [], PermissionButton = Button, onSuccess, + onClose, taskId = null, cloneMode = false, }) => { const [drawerVisible, setDrawerVisible] = useState(false); + const [formKey, setFormKey] = useState(0); const userSettingsDefaults = useSettings(); const formControl = useForm({ @@ -28,12 +30,21 @@ export const CippSchedulerDrawer = ({ const handleCloseDrawer = () => { setDrawerVisible(false); - // Reset form to default values - formControl.reset({ - tenantFilter: userSettingsDefaults.currentTenant, - Recurrence: { value: "0", label: "Once" }, - taskType: { value: "scheduled", label: "Scheduled Task" }, - }); + // Increment form key to force complete remount when reopening + setFormKey((prev) => prev + 1); + // Call onClose callback if provided (to clear parent state) + if (onClose) { + onClose(); + } + // Add a small delay before resetting to ensure drawer is closed + setTimeout(() => { + // Reset form to default values + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + Recurrence: { value: "0", label: "Once" }, + taskType: { value: "scheduled", label: "Scheduled Task" }, + }); + }, 100); }; const handleOpenDrawer = () => { @@ -73,6 +84,7 @@ export const CippSchedulerDrawer = ({ { key={`edit-${editTaskId}`} taskId={editTaskId} onSuccess={() => setEditTaskId(null)} + onClose={() => setEditTaskId(null)} PermissionButton={({ children }) => <>{children}} /> )} @@ -102,6 +103,7 @@ const Page = () => { taskId={cloneTaskId} cloneMode={true} onSuccess={() => setCloneTaskId(null)} + onClose={() => setCloneTaskId(null)} PermissionButton={({ children }) => <>{children}} /> )} From 81a19531cc47f5cb7805d6fda3a902c03104cb97 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 22 Oct 2025 17:35:31 -0400 Subject: [PATCH 31/41] move api results to footer --- .../CippComponents/CippAddUserDrawer.jsx | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/CippComponents/CippAddUserDrawer.jsx b/src/components/CippComponents/CippAddUserDrawer.jsx index a0802bde5f5c..f6a650ce1b11 100644 --- a/src/components/CippComponents/CippAddUserDrawer.jsx +++ b/src/components/CippComponents/CippAddUserDrawer.jsx @@ -8,6 +8,7 @@ import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { ApiPostCall } from "../../api/ApiCall"; import CippAddEditUser from "../CippFormPages/CippAddEditUser"; +import { Stack } from "@mui/system"; export const CippAddUserDrawer = ({ buttonText = "Add User", @@ -105,23 +106,26 @@ export const CippAddUserDrawer = ({ onClose={handleCloseDrawer} size="xl" footer={ -
- - -
+ + +
+ + +
+
} > @@ -162,7 +166,6 @@ export const CippAddUserDrawer = ({ formType="add" /> - ); From 3669d224fc1b62be41c410e498ec4d1f54c6396a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 22 Oct 2025 23:57:45 -0400 Subject: [PATCH 32/41] tenant group improvements added offcanvas view for logs and group info --- .../CippComponents/CippApiLogsDrawer.jsx | 95 ++++++ .../CippTenantGroupOffCanvas.jsx | 273 ++++++++++++++++++ src/components/CippTable/CippDataTable.js | 6 +- .../identity/administration/users/index.js | 8 + .../administration/tenants/groups/index.js | 23 +- 5 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 src/components/CippComponents/CippApiLogsDrawer.jsx create mode 100644 src/components/CippComponents/CippTenantGroupOffCanvas.jsx diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx new file mode 100644 index 000000000000..3349e891a9f5 --- /dev/null +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { Button, Box } from "@mui/material"; +import { History } from "@mui/icons-material"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippApiLogsDrawer = ({ + buttonText = "View API Logs", + apiFilter = null, + tenantFilter = null, + requiredPermissions = [], + PermissionButton = Button, + title = "API Logs", + ...props +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const handleCloseDrawer = () => { + setDrawerVisible(false); + }; + + const handleOpenDrawer = () => { + setDrawerVisible(true); + }; + + // Build the API URL with the filter + const apiUrl = `/api/ListLogs?Filter=true${apiFilter ? `&API=${apiFilter}` : ""}${ + tenantFilter ? `&Tenant=${tenantFilter}` : "" + }`; + + // Define the columns for the logs table + const simpleColumns = ["DateTime", "Severity", "Message", "User", "Tenant", "API"]; + + const actions = [ + { + label: "View Log Entry", + link: "/cipp/logs/logentry?logentry=[RowKey]", + icon: , + color: "primary", + }, + ]; + + return ( + <> + } + {...props} + > + {buttonText} + + + + + + + + ); +}; diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx new file mode 100644 index 000000000000..3caf715a9972 --- /dev/null +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -0,0 +1,273 @@ +import React from "react"; +import { + Box, + Typography, + Card, + CardContent, + Chip, + Alert, + AlertTitle, + useTheme, + Stack, +} from "@mui/material"; +import { Groups, Business, Rule, Info } from "@mui/icons-material"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippTenantGroupOffCanvas = ({ data }) => { + const theme = useTheme(); + + if (!data) { + return ( + + No group data available + + ); + } + + const isDynamic = data.GroupType === "dynamic"; + const hasMembers = data.Members && data.Members.length > 0; + const hasDynamicRules = + data.DynamicRules && + ((Array.isArray(data.DynamicRules) && data.DynamicRules.length > 0) || + (!Array.isArray(data.DynamicRules) && Object.keys(data.DynamicRules).length > 0)); + + const renderDynamicRules = () => { + if (!hasDynamicRules) { + return ( + + No Dynamic Rules + This dynamic group has no rules configured. + + ); + } + + const operatorDisplay = { + eq: "equals", + ne: "not equals", + contains: "contains", + startsWith: "starts with", + endsWith: "ends with", + }; + + // Handle both single rule object and array of rules + const rules = Array.isArray(data.DynamicRules) ? data.DynamicRules : [data.DynamicRules]; + + const renderRule = (rule, index) => ( + + + Rule {rules.length > 1 ? `${index + 1}:` : "Configuration:"} + + + Property: {rule.property} + + + Operator: {operatorDisplay[rule.operator] || rule.operator} + + + Value(s): + + {Array.isArray(rule.value) ? ( + + {rule.value.map((item, valueIndex) => ( + + ))} + + ) : ( + + )} + + ); + + const renderRulesWithLogic = () => { + if (rules.length === 1) { + return renderRule(rules[0], 0); + } + + return rules.map((rule, index) => ( + + {renderRule(rule, index)} + {index < rules.length - 1 && ( + + + + )} + + )); + }; + + return ( + + + + + Dynamic Rules + {rules.length > 1 && ( + + )} + + {renderRulesWithLogic()} + + + ); + }; + + const renderMembers = () => { + if (!hasMembers) { + return ( + + No Members + {isDynamic + ? "This dynamic group has no members that match the current rules." + : "This static group has no members assigned."} + + ); + } + + const memberColumns = ["displayName", "defaultDomainName", "customerId"]; + + return ( + + , + }} + /> + + ); + }; + + return ( + + {/* Header Section */} + + + + + + {data.Name} + + + + + ID: {data.Id} + + + + + + {data.Description && ( + + Description + {data.Description} + + )} + + + {/* Content Sections */} + + {/* Dynamic Rules Section (only for dynamic groups) */} + {isDynamic && {renderDynamicRules()}} + + {/* Members Section */} + {renderMembers()} + + {/* Additional Info */} + + + + + Additional Information + + + + + Group Type + + {isDynamic ? "Dynamic" : "Static"} + + + + Member Count + + + {data.Members?.length || 0} tenant{(data.Members?.length || 0) !== 1 ? "s" : ""} + + + {isDynamic && ( + <> + + + Rule Logic + + + {data.RuleLogic?.toUpperCase() || "AND"} + + + + + Has Rules + + {hasDynamicRules ? "Yes" : "No"} + + + )} + + + + + + ); +}; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 2cfba0bc9640..a1238dfe7c16 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -646,7 +646,11 @@ export const CippDataTable = (props) => { {cardButton || !hideTitle ? ( <> - + ) : null} diff --git a/src/pages/identity/administration/users/index.js b/src/pages/identity/administration/users/index.js index 8baf9eaaf76b..fb2c8115cd44 100644 --- a/src/pages/identity/administration/users/index.js +++ b/src/pages/identity/administration/users/index.js @@ -6,6 +6,7 @@ import { useCippUserActions } from "/src/components/CippComponents/CippUserActio import { CippInviteGuestDrawer } from "/src/components/CippComponents/CippInviteGuestDrawer.jsx"; import { CippBulkUserDrawer } from "/src/components/CippComponents/CippBulkUserDrawer.jsx"; import { CippAddUserDrawer } from "/src/components/CippComponents/CippAddUserDrawer.jsx"; +import { CippApiLogsDrawer } from "/src/components/CippComponents/CippApiLogsDrawer.jsx"; const Page = () => { const userActions = useCippUserActions(); @@ -70,6 +71,13 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> + } apiData={{ diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js index 64647625dc9c..b551bf629039 100644 --- a/src/pages/tenant/administration/tenants/groups/index.js +++ b/src/pages/tenant/administration/tenants/groups/index.js @@ -5,12 +5,21 @@ import tabOptions from "../tabOptions"; import { Edit, PlayArrow } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { CippAddTenantGroupDrawer } from "/src/components/CippComponents/CippAddTenantGroupDrawer"; +import { CippApiLogsDrawer } from "/src/components/CippComponents/CippApiLogsDrawer"; +import { CippTenantGroupOffCanvas } from "/src/components/CippComponents/CippTenantGroupOffCanvas"; +import { Box } from "@mui/material"; const Page = () => { const pageTitle = "Tenant Groups"; const simpleColumns = ["Name", "Description", "GroupType", "Members"]; + const offcanvas = { + children: (row) => { + return ; + }, + size: "xl", + }; const actions = [ { label: "Edit Group", @@ -25,7 +34,7 @@ const Page = () => { data: { groupId: "Id" }, queryKey: "TenantGroupListPage", confirmText: "Are you sure you want to run dynamic rules for [Name]?", - condition: (row) => row.GroupType === 'dynamic' + condition: (row) => row.GroupType === "dynamic", }, { label: "Delete Group", @@ -35,7 +44,7 @@ const Page = () => { data: { action: "Delete", groupId: "Id" }, queryKey: "TenantGroupListPage", confirmText: "Are you sure you want to delete [Name]?", - } + }, ]; return ( @@ -48,8 +57,16 @@ const Page = () => { apiDataKey="Results" actions={actions} cardButton={ - + + + + } + offCanvas={offcanvas} /> ); }; From 9061d4fe62ca1f8b81860cb477bc77f5ee6197c1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 00:11:24 -0400 Subject: [PATCH 33/41] Update CippTenantGroupOffCanvas.jsx --- src/components/CippComponents/CippTenantGroupOffCanvas.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx index 3caf715a9972..05ed8e18f836 100644 --- a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -44,6 +44,8 @@ export const CippTenantGroupOffCanvas = ({ data }) => { const operatorDisplay = { eq: "equals", ne: "not equals", + in: "in", + notIn: "not in", contains: "contains", startsWith: "starts with", endsWith: "ends with", From 5bb4349f4d3bf2b4d73187ab167bcd5417dd5044 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:16:05 +0200 Subject: [PATCH 34/41] automated onboarding, default group creation --- src/pages/cipp/settings/partner-webhooks.js | 2 +- .../administration/tenants/groups/index.js | 66 ++++++++++++------- .../tenant/gdap-management/invites/add.js | 2 +- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/pages/cipp/settings/partner-webhooks.js b/src/pages/cipp/settings/partner-webhooks.js index 67d218fb22c6..f5ac4dd87889 100644 --- a/src/pages/cipp/settings/partner-webhooks.js +++ b/src/pages/cipp/settings/partner-webhooks.js @@ -27,7 +27,7 @@ import { useState } from "react"; import { Close } from "@mui/icons-material"; const Page = () => { - const pageTitle = "Partner Webhooks"; + const pageTitle = "Automated onboarding"; const [testRunning, setTestRunning] = useState(false); const [correlationId, setCorrelationId] = useState(null); const [validateRunning, setValidateRunning] = useState(false); diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js index b551bf629039..3856d7ffc4b9 100644 --- a/src/pages/tenant/administration/tenants/groups/index.js +++ b/src/pages/tenant/administration/tenants/groups/index.js @@ -2,15 +2,18 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { TabbedLayout } from "/src/layouts/TabbedLayout"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import tabOptions from "../tabOptions"; -import { Edit, PlayArrow } from "@mui/icons-material"; +import { Edit, PlayArrow, GroupAdd } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { CippAddTenantGroupDrawer } from "/src/components/CippComponents/CippAddTenantGroupDrawer"; import { CippApiLogsDrawer } from "/src/components/CippComponents/CippApiLogsDrawer"; import { CippTenantGroupOffCanvas } from "/src/components/CippComponents/CippTenantGroupOffCanvas"; -import { Box } from "@mui/material"; +import { CippApiDialog } from "/src/components/CippComponents/CippApiDialog.jsx"; +import { Box, Button } from "@mui/material"; +import { useDialog } from "/src/hooks/use-dialog.js"; const Page = () => { const pageTitle = "Tenant Groups"; + const createDefaultGroupsDialog = useDialog(); const simpleColumns = ["Name", "Description", "GroupType", "Members"]; @@ -48,26 +51,45 @@ const Page = () => { ]; return ( - - - - - } - offCanvas={offcanvas} - /> + <> + + + + + + } + offCanvas={offcanvas} + /> + + ); }; diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js index 22a45e334d1d..ce6fe95f1226 100644 --- a/src/pages/tenant/gdap-management/invites/add.js +++ b/src/pages/tenant/gdap-management/invites/add.js @@ -118,7 +118,7 @@ const Page = () => { The onboarding process will also run on a nightly schedule. For automated onboardings, please check out{" "} - Partner Webhooks + Automated Onboarding {" "} in Application Settings. From 70af2d14f32f6705792b25a55d189587a55b7d31 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:57:05 +0200 Subject: [PATCH 35/41] add default groups button --- src/pages/tenant/administration/tenants/groups/index.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/tenant/administration/tenants/groups/index.js b/src/pages/tenant/administration/tenants/groups/index.js index 3856d7ffc4b9..ea0416d936bf 100644 --- a/src/pages/tenant/administration/tenants/groups/index.js +++ b/src/pages/tenant/administration/tenants/groups/index.js @@ -63,10 +63,7 @@ const Page = () => { cardButton={ - { type: "POST", url: "/api/ExecCreateDefaultGroups", data: {}, - confirmText: "Are you sure you want to create default tenant groups? This will create standard groups like 'All Tenants', 'Enterprise Tenants', etc.", + confirmText: + "Are you sure you want to create default tenant groups? This will create a selection of groups we recommend by default to use as templates.", }} queryKey="TenantGroupListPage" /> From b5acbe5dd7030f5603d2b38c40b2625846436554 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 09:09:58 -0400 Subject: [PATCH 36/41] improve resource selection box --- .../CippGraphResourceSelector.jsx | 60 ++++++++++++++++--- .../CippFormPages/CippSchedulerForm.jsx | 4 +- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/components/CippComponents/CippGraphResourceSelector.jsx b/src/components/CippComponents/CippGraphResourceSelector.jsx index 1efdf9479e7f..f2c2015c61f8 100644 --- a/src/components/CippComponents/CippGraphResourceSelector.jsx +++ b/src/components/CippComponents/CippGraphResourceSelector.jsx @@ -17,11 +17,11 @@ const CippGraphResourceSelector = ({ formControl, name, resourceFieldName = "DeltaResource", + tenantFilterFieldName = "tenantFilter", label = "Filter Specific Resources (Optional)", helperText, multiple = true, required = false, - tenantFilter, ...otherProps }) => { // Watch for changes in the resource type field @@ -30,9 +30,18 @@ const CippGraphResourceSelector = ({ name: resourceFieldName, }); + // Watch for changes in the tenant filter field + const tenantFilter = useWatch({ + control: formControl.control, + name: tenantFilterFieldName, + }); + // Extract the value whether selectedResource is an object or string const resourceValue = selectedResource?.value || selectedResource; + // Extract the tenant filter value - handle both object and string formats + const tenantFilterValue = tenantFilter?.value || tenantFilter; + const getHelperText = () => { if (helperText) return helperText; @@ -40,6 +49,14 @@ const CippGraphResourceSelector = ({ return "Select a resource type above to filter specific resources"; } + if ( + !tenantFilterValue || + tenantFilterValue === "AllTenants" || + (tenantFilter && typeof tenantFilter === "object" && tenantFilter.type === "Group") + ) { + return "Resource filtering is not available for All Tenants or tenant groups"; + } + if (multiple) { return "Optionally select specific resources to monitor (will create filter with OR statements: id eq 'id1' or id eq 'id2')"; } @@ -47,21 +64,44 @@ const CippGraphResourceSelector = ({ return "Optionally select a specific resource to monitor"; }; - const api = resourceValue + // Check if we should make the API call + const shouldFetchResources = () => { + // Must have a resource type selected + if (!resourceValue) return false; + + // Must have a tenant filter + if (!tenantFilterValue) return false; + + // Cannot be null or undefined + if (tenantFilterValue === null || tenantFilterValue === undefined) return false; + + // Cannot be AllTenants + if (tenantFilterValue === "AllTenants") return false; + + // Cannot be a tenant group (check if tenantFilter object has type: "Group") + if (tenantFilter && typeof tenantFilter === "object" && tenantFilter.type === "Group") + return false; + + return true; + }; + + const isDisabled = !resourceValue || !shouldFetchResources(); + + const api = shouldFetchResources() ? { url: "/api/ListGraphRequest", - queryKey: `graph-resources-${resourceValue}-${tenantFilter}`, + queryKey: `graph-resources-${resourceValue}-${tenantFilterValue}`, data: { Endpoint: resourceValue, IgnoreErrors: true, $select: "id,displayName", $top: 100, - tenantFilter, + tenantFilter: tenantFilterValue, }, labelField: (item) => item.displayName || item.id, valueField: "id", dataKey: "Results", - waiting: tenantFilter ? true : false, + waiting: true, } : null; @@ -73,11 +113,17 @@ const CippGraphResourceSelector = ({ multiple={multiple} creatable={false} required={required} - disabled={!resourceValue} + disabled={isDisabled} formControl={formControl} api={api} helperText={getHelperText()} - placeholder={!resourceValue ? "Select a resource type first" : undefined} + placeholder={ + !resourceValue + ? "Select a resource type first" + : !shouldFetchResources() + ? "Resource filtering not available" + : undefined + } {...otherProps} /> ); diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index f46f29fbd93e..50be6839a2e9 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -666,6 +666,7 @@ const CippSchedulerForm = (props) => { name="Trigger.ResourceFilter" formControl={formControl} resourceFieldName="Trigger.DeltaResource" + tenantFilterFieldName="tenantFilter" label="Filter Specific Resources (Optional)" multiple={true} required={false} @@ -675,9 +676,6 @@ const CippSchedulerForm = (props) => { ? "Resource filtering is not available when All Tenants or tenant groups are selected" : "Select specific resources to monitor" } - tenantFilter={ - selectedTenant?.type !== "Group" ? selectedTenant.value : null - } />
From ae16177f6ddda03736bd352478fb9c2818cebb01 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 10:37:44 -0400 Subject: [PATCH 37/41] add right of boom logo --- src/layouts/side-nav.js | 7 ++++++- src/pages/identity/administration/users/index.js | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index 47c601fa3027..772671f5e3db 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -135,6 +135,11 @@ export const SideNav = (props) => { imagesrc: "/sponsors/huntress_teal.png", priority: 1, }, + { + link: "https://rightofboom.com/rob-2026-overview/rob-2026-registration/?utm_source=CIPP&utm_medium=referral&utm_campaign=CIPPM365&utm_content=cta_button", + imagesrc: theme === "light" ? "/sponsors/RoB-light.png" : "/sponsors/RoB.png", + priority: 1, + }, ]; const randomSponsorImage = () => { @@ -237,7 +242,7 @@ export const SideNav = (props) => { cursor: "pointer", maxHeight: "50px", // Limit the height of the image width: "auto", - maxWidth: "100px" // Maintain aspect ratio with max width + maxWidth: "100px", // Maintain aspect ratio with max width }} onClick={() => window.open(randomimg.link)} /> diff --git a/src/pages/identity/administration/users/index.js b/src/pages/identity/administration/users/index.js index fb2c8115cd44..27148701979b 100644 --- a/src/pages/identity/administration/users/index.js +++ b/src/pages/identity/administration/users/index.js @@ -7,6 +7,7 @@ import { CippInviteGuestDrawer } from "/src/components/CippComponents/CippInvite import { CippBulkUserDrawer } from "/src/components/CippComponents/CippBulkUserDrawer.jsx"; import { CippAddUserDrawer } from "/src/components/CippComponents/CippAddUserDrawer.jsx"; import { CippApiLogsDrawer } from "/src/components/CippComponents/CippApiLogsDrawer.jsx"; +import { Box } from "@mui/material"; const Page = () => { const userActions = useCippUserActions(); @@ -58,7 +59,7 @@ const Page = () => { title={pageTitle} apiUrl="/api/ListGraphRequest" cardButton={ - <> + { PermissionButton={PermissionButton} tenantFilter={tenant} /> - + } apiData={{ Endpoint: "users", From 09719893e7e036a27fe2527af413aa31c0bccb02 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 12:09:03 -0400 Subject: [PATCH 38/41] fix edit template --- src/pages/endpoint/MEM/list-templates/edit.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/endpoint/MEM/list-templates/edit.jsx b/src/pages/endpoint/MEM/list-templates/edit.jsx index 71eb1bc6c00c..c58cbe7e0920 100644 --- a/src/pages/endpoint/MEM/list-templates/edit.jsx +++ b/src/pages/endpoint/MEM/list-templates/edit.jsx @@ -19,7 +19,7 @@ const EditIntuneTemplate = () => { }); const templateData = Array.isArray(templateQuery.data) - ? templateQuery.data.find((t) => t.id === id) + ? templateQuery.data.find((t) => t.id === id || t.GUID === id) : templateQuery.data; // Custom data formatter to convert autoComplete objects to values From 32c8b9d0af02d87bd3b0f8f69684622b574423c3 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 15:15:53 -0400 Subject: [PATCH 39/41] add issynced prop --- src/pages/endpoint/MEM/list-templates/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/endpoint/MEM/list-templates/index.js b/src/pages/endpoint/MEM/list-templates/index.js index 2c06faf5f4da..d35cf8dc097e 100644 --- a/src/pages/endpoint/MEM/list-templates/index.js +++ b/src/pages/endpoint/MEM/list-templates/index.js @@ -139,13 +139,14 @@ const Page = () => { size: "lg", }; - const simpleColumns = ["displayName", "package", "description", "Type"]; + const simpleColumns = ["displayName", "isSynced", "package", "description", "Type"]; return ( <> Date: Thu, 23 Oct 2025 21:15:56 +0200 Subject: [PATCH 40/41] version up --- public/version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/version.json b/public/version.json index d0e25ec56792..c9486452da33 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.5.2" -} \ No newline at end of file + "version": "8.6.0" +} From 73e2d0b838f31b2e7f54f77468e828a6ce0e464a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 23 Oct 2025 15:41:18 -0400 Subject: [PATCH 41/41] Update index.js --- src/pages/endpoint/MEM/devices/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index d4068f3e63f3..2afc598b490a 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -157,7 +157,7 @@ const Page = () => { confirmText: "Are you sure you want to retrieve the BitLocker keys for [deviceName]?", }, { - label: "Retrieve File Fault Key", + label: "Retrieve File Vault Key", type: "POST", icon: , url: "/api/ExecDeviceAction",